mirror of
https://github.com/fosrl/pangolin.git
synced 2026-02-17 18:36:37 +00:00
Compare commits
51 Commits
1.15.3
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
24848c2d8c | ||
|
|
9eacefb155 | ||
|
|
843b13ed57 | ||
|
|
be89e5ca55 | ||
|
|
333625f199 | ||
|
|
dbfd715381 | ||
|
|
f1d989964e | ||
|
|
b701629498 | ||
|
|
8250946325 | ||
|
|
71f63d8e6f | ||
|
|
dd5e834db0 | ||
|
|
970ecb52f0 | ||
|
|
62ea1b40e1 | ||
|
|
3b0fd5c592 | ||
|
|
b7616026dd | ||
|
|
16ad60b89a | ||
|
|
db7971d2f7 | ||
|
|
f3f8bd3125 | ||
|
|
516fd0ee8f | ||
|
|
8d6700d493 | ||
|
|
9d4ace9b3e | ||
|
|
2800655e33 | ||
|
|
91eecee11d | ||
|
|
899e5aa395 | ||
|
|
d5820c4902 | ||
|
|
a91c002274 | ||
|
|
4d142b93dd | ||
|
|
04dcf57ff3 | ||
|
|
975550c755 | ||
|
|
a964a80d85 | ||
|
|
22c3b8f116 | ||
|
|
c4b1831cfe | ||
|
|
cdb6813384 | ||
|
|
b14b68d83c | ||
|
|
3c2f930e6b | ||
|
|
ca9c7ce555 | ||
|
|
c2e95a0607 | ||
|
|
2767ee9e80 | ||
|
|
d998a8087f | ||
|
|
fdce016921 | ||
|
|
c73d70933b | ||
|
|
e9d0ad6e37 | ||
|
|
a35586f762 | ||
|
|
f527c30923 | ||
|
|
94e70219cf | ||
|
|
6496763aae | ||
|
|
a409ec269b | ||
|
|
bc7bc8da66 | ||
|
|
52484c774e | ||
|
|
69ecc22318 | ||
|
|
bff9d33ee6 |
41
.github/workflows/cicd.yml
vendored
41
.github/workflows/cicd.yml
vendored
@@ -1,4 +1,4 @@
|
||||
name: Public Pipeline
|
||||
name: Public CICD Pipeline
|
||||
|
||||
# CI/CD workflow for building, publishing, mirroring, signing container images and building release binaries.
|
||||
# Actions are pinned to specific SHAs to reduce supply-chain risk. This workflow triggers on tag push events.
|
||||
@@ -525,41 +525,10 @@ jobs:
|
||||
VERIFIED_INDEX_KEYLESS=false
|
||||
fi
|
||||
|
||||
# If index verification fails, attempt to verify child platform manifests
|
||||
if [ "${VERIFIED_INDEX}" != "true" ] || [ "${VERIFIED_INDEX_KEYLESS}" != "true" ]; then
|
||||
echo "Index verification not available; attempting child manifest verification for ${BASE_IMAGE}:${IMAGE_TAG}"
|
||||
CHILD_VERIFIED=false
|
||||
|
||||
for ARCH in arm64 amd64; do
|
||||
CHILD_TAG="${IMAGE_TAG}-${ARCH}"
|
||||
echo "Resolving child digest for ${BASE_IMAGE}:${CHILD_TAG}"
|
||||
CHILD_DIGEST="$(skopeo inspect --retry-times 3 docker://${BASE_IMAGE}:${CHILD_TAG} | jq -r '.Digest' || true)"
|
||||
if [ -n "${CHILD_DIGEST}" ] && [ "${CHILD_DIGEST}" != "null" ]; then
|
||||
CHILD_REF="${BASE_IMAGE}@${CHILD_DIGEST}"
|
||||
echo "==> cosign verify (public key) child ${CHILD_REF}"
|
||||
if retry_verify "cosign verify --key env://COSIGN_PUBLIC_KEY '${CHILD_REF}' -o text"; then
|
||||
CHILD_VERIFIED=true
|
||||
echo "Public key verification succeeded for child ${CHILD_REF}"
|
||||
else
|
||||
echo "Public key verification failed for child ${CHILD_REF}"
|
||||
fi
|
||||
|
||||
echo "==> cosign verify (keyless policy) child ${CHILD_REF}"
|
||||
if retry_verify "cosign verify --certificate-oidc-issuer '${issuer}' --certificate-identity-regexp '${id_regex}' '${CHILD_REF}' -o text"; then
|
||||
CHILD_VERIFIED=true
|
||||
echo "Keyless verification succeeded for child ${CHILD_REF}"
|
||||
else
|
||||
echo "Keyless verification failed for child ${CHILD_REF}"
|
||||
fi
|
||||
else
|
||||
echo "No child digest found for ${BASE_IMAGE}:${CHILD_TAG}; skipping"
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "${CHILD_VERIFIED}" != "true" ]; then
|
||||
echo "Failed to verify index and no child manifests verified for ${BASE_IMAGE}:${IMAGE_TAG}"
|
||||
exit 1
|
||||
fi
|
||||
# Check if verification succeeded
|
||||
if [ "${VERIFIED_INDEX}" != "true" ] && [ "${VERIFIED_INDEX_KEYLESS}" != "true" ]; then
|
||||
echo "⚠️ WARNING: Verification not available for ${BASE_IMAGE}:${IMAGE_TAG}"
|
||||
echo "This may be due to registry propagation delays. Continuing anyway."
|
||||
fi
|
||||
) || TAG_FAILED=true
|
||||
|
||||
|
||||
2
.github/workflows/test.yml
vendored
2
.github/workflows/test.yml
vendored
@@ -37,7 +37,7 @@ jobs:
|
||||
run: npm run db:generate
|
||||
|
||||
- name: Apply database migrations
|
||||
run: npm run db:sqlite:push
|
||||
run: npm run db:push
|
||||
|
||||
- name: Test with tsc
|
||||
run: npx tsc --noEmit
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -51,4 +51,5 @@ dynamic/
|
||||
scratch/
|
||||
tsconfig.json
|
||||
hydrateSaas.ts
|
||||
CLAUDE.md
|
||||
CLAUDE.md
|
||||
drizzle.config.ts
|
||||
|
||||
@@ -16,7 +16,7 @@ COPY . .
|
||||
RUN if [ "$BUILD" = "oss" ]; then rm -rf server/private; fi && \
|
||||
npm run set:$DATABASE && \
|
||||
npm run set:$BUILD && \
|
||||
npm run db:$DATABASE:generate && \
|
||||
npm run db:generate && \
|
||||
npm run build && \
|
||||
npm run build:cli
|
||||
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import { defineConfig } from "drizzle-kit";
|
||||
import path from "path";
|
||||
|
||||
const schema = [path.join("server", "db", "pg", "schema")];
|
||||
|
||||
export default defineConfig({
|
||||
dialect: "postgresql",
|
||||
schema: schema,
|
||||
out: path.join("server", "migrations"),
|
||||
verbose: true,
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL as string
|
||||
}
|
||||
});
|
||||
@@ -1154,7 +1154,7 @@
|
||||
"actionDeleteClient": "Client löschen",
|
||||
"actionArchiveClient": "Client archivieren",
|
||||
"actionUnarchiveClient": "Client dearchivieren",
|
||||
"actionBlockClient": "Klient sperren",
|
||||
"actionBlockClient": "Client sperren",
|
||||
"actionUnblockClient": "Client entsperren",
|
||||
"actionUpdateClient": "Client aktualisieren",
|
||||
"actionListClients": "Clients auflisten",
|
||||
@@ -2532,10 +2532,10 @@
|
||||
"archiveClientQuestion": "Sind Sie sicher, dass Sie diesen Client archivieren möchten?",
|
||||
"archiveClientMessage": "Der Client wird archiviert und aus der Liste Ihrer aktiven Clients entfernt.",
|
||||
"archiveClientConfirm": "Client archivieren",
|
||||
"blockClient": "Klient sperren",
|
||||
"blockClient": "Client sperren",
|
||||
"blockClientQuestion": "Sind Sie sicher, dass Sie diesen Client blockieren möchten?",
|
||||
"blockClientMessage": "Das Gerät wird gezwungen, die Verbindung zu trennen, wenn es gerade verbunden ist. Sie können das Gerät später entsperren.",
|
||||
"blockClientConfirm": "Klient sperren",
|
||||
"blockClientConfirm": "Client sperren",
|
||||
"active": "Aktiv",
|
||||
"usernameOrEmail": "Benutzername oder E-Mail",
|
||||
"selectYourOrganization": "Wählen Sie Ihre Organisation",
|
||||
|
||||
@@ -201,6 +201,7 @@
|
||||
"protocolSelect": "Select a protocol",
|
||||
"resourcePortNumber": "Port Number",
|
||||
"resourcePortNumberDescription": "The external port number to proxy requests.",
|
||||
"back": "Back",
|
||||
"cancel": "Cancel",
|
||||
"resourceConfig": "Configuration Snippets",
|
||||
"resourceConfigDescription": "Copy and paste these configuration snippets to set up the TCP/UDP resource",
|
||||
@@ -246,6 +247,17 @@
|
||||
"orgErrorDeleteMessage": "An error occurred while deleting the organization.",
|
||||
"orgDeleted": "Organization deleted",
|
||||
"orgDeletedMessage": "The organization and its data has been deleted.",
|
||||
"deleteAccount": "Delete Account",
|
||||
"deleteAccountDescription": "Permanently delete your account, all organizations you own, and all data within those organizations. This cannot be undone.",
|
||||
"deleteAccountButton": "Delete Account",
|
||||
"deleteAccountConfirmTitle": "Delete Account",
|
||||
"deleteAccountConfirmMessage": "This will permanently wipe your account, all organizations you own, and all data within those organizations. This cannot be undone.",
|
||||
"deleteAccountConfirmString": "delete account",
|
||||
"deleteAccountSuccess": "Account Deleted",
|
||||
"deleteAccountSuccessMessage": "Your account has been deleted.",
|
||||
"deleteAccountError": "Failed to delete account",
|
||||
"deleteAccountPreviewAccount": "Your Account",
|
||||
"deleteAccountPreviewOrgs": "Organizations you own (and all their data)",
|
||||
"orgMissing": "Organization ID Missing",
|
||||
"orgMissingMessage": "Unable to regenerate invitation without an organization ID.",
|
||||
"accessUsersManage": "Manage Users",
|
||||
@@ -2060,7 +2072,7 @@
|
||||
"machineClientsBannerDescription": "Machine clients are for servers and automated systems that are not associated with a specific user. They authenticate with an ID and secret, and can run with Pangolin CLI, Olm CLI, or Olm as a container.",
|
||||
"machineClientsBannerPangolinCLI": "Pangolin CLI",
|
||||
"machineClientsBannerOlmCLI": "Olm CLI",
|
||||
"machineClientsBannerOlmContainer": "Olm Container",
|
||||
"machineClientsBannerOlmContainer": "Container",
|
||||
"clientsTableUserClients": "User",
|
||||
"clientsTableMachineClients": "Machine",
|
||||
"licenseTableValidUntil": "Valid Until",
|
||||
|
||||
843
package-lock.json
generated
843
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
47
package.json
47
package.json
@@ -13,13 +13,10 @@
|
||||
"scripts": {
|
||||
"dev": "NODE_ENV=development ENVIRONMENT=dev tsx watch server/index.ts",
|
||||
"dev:check": "npx tsc --noEmit && npm run format:check",
|
||||
"dev:setup": "cp config/config.example.yml config/config.yml && npm run set:oss && npm run set:sqlite && npm run db:generate && npm run db:sqlite:push",
|
||||
"db:pg:generate": "drizzle-kit generate --config=./drizzle.pg.config.ts",
|
||||
"db:sqlite:generate": "drizzle-kit generate --config=./drizzle.sqlite.config.ts",
|
||||
"db:pg:push": "npx tsx server/db/pg/migrate.ts",
|
||||
"db:sqlite:push": "npx tsx server/db/sqlite/migrate.ts",
|
||||
"db:pg:studio": "drizzle-kit studio --config=./drizzle.pg.config.ts",
|
||||
"db:sqlite:studio": "drizzle-kit studio --config=./drizzle.sqlite.config.ts",
|
||||
"dev:setup": "cp config/config.example.yml config/config.yml && npm run set:oss && npm run set:sqlite && npm run db:sqlite:generate && npm run db:sqlite:push",
|
||||
"db:generate": "drizzle-kit generate --config=./drizzle.config.ts",
|
||||
"db:push": "npx tsx server/db/migrate.ts",
|
||||
"db:studio": "drizzle-kit studio --config=./drizzle.config.ts",
|
||||
"db:clear-migrations": "rm -rf server/migrations",
|
||||
"set:oss": "echo 'export const build = \"oss\" as \"saas\" | \"enterprise\" | \"oss\";' > server/build.ts && cp tsconfig.oss.json tsconfig.json",
|
||||
"set:saas": "echo 'export const build = \"saas\" as \"saas\" | \"enterprise\" | \"oss\";' > server/build.ts && cp tsconfig.saas.json tsconfig.json",
|
||||
@@ -35,7 +32,7 @@
|
||||
"format": "prettier --write ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@asteasolutions/zod-to-openapi": "8.4.0",
|
||||
"@asteasolutions/zod-to-openapi": "8.4.1",
|
||||
"@aws-sdk/client-s3": "3.971.0",
|
||||
"@faker-js/faker": "10.2.0",
|
||||
"@headlessui/react": "2.2.9",
|
||||
@@ -62,41 +59,41 @@
|
||||
"@radix-ui/react-tabs": "1.1.13",
|
||||
"@radix-ui/react-toast": "1.2.15",
|
||||
"@radix-ui/react-tooltip": "1.2.8",
|
||||
"@react-email/components": "1.0.2",
|
||||
"@react-email/render": "2.0.0",
|
||||
"@react-email/tailwind": "2.0.2",
|
||||
"@react-email/components": "1.0.7",
|
||||
"@react-email/render": "2.0.4",
|
||||
"@react-email/tailwind": "2.0.4",
|
||||
"@simplewebauthn/browser": "13.2.2",
|
||||
"@simplewebauthn/server": "13.2.2",
|
||||
"@tailwindcss/forms": "0.5.11",
|
||||
"@tanstack/react-query": "5.90.12",
|
||||
"@tanstack/react-query": "5.90.21",
|
||||
"@tanstack/react-table": "8.21.3",
|
||||
"arctic": "3.7.0",
|
||||
"axios": "1.13.2",
|
||||
"axios": "1.13.5",
|
||||
"better-sqlite3": "11.9.1",
|
||||
"canvas-confetti": "1.9.4",
|
||||
"class-variance-authority": "0.7.1",
|
||||
"clsx": "2.1.1",
|
||||
"cmdk": "1.1.1",
|
||||
"cookie-parser": "1.4.7",
|
||||
"cors": "2.8.5",
|
||||
"cors": "2.8.6",
|
||||
"crypto-js": "4.2.0",
|
||||
"d3": "7.9.0",
|
||||
"date-fns": "4.1.0",
|
||||
"drizzle-orm": "0.45.1",
|
||||
"eslint": "9.39.2",
|
||||
"eslint-config-next": "16.1.0",
|
||||
"eslint-config-next": "16.1.6",
|
||||
"express": "5.2.1",
|
||||
"express-rate-limit": "8.2.1",
|
||||
"glob": "13.0.0",
|
||||
"glob": "13.0.3",
|
||||
"helmet": "8.1.0",
|
||||
"http-errors": "2.0.1",
|
||||
"input-otp": "1.4.2",
|
||||
"ioredis": "5.9.2",
|
||||
"ioredis": "5.9.3",
|
||||
"jmespath": "0.16.0",
|
||||
"js-yaml": "4.1.1",
|
||||
"jsonwebtoken": "9.0.3",
|
||||
"lucide-react": "0.562.0",
|
||||
"maxmind": "5.0.1",
|
||||
"maxmind": "5.0.5",
|
||||
"moment": "2.30.1",
|
||||
"next": "15.5.9",
|
||||
"next-intl": "4.7.0",
|
||||
@@ -108,19 +105,19 @@
|
||||
"pg": "8.17.1",
|
||||
"posthog-node": "5.23.0",
|
||||
"qrcode.react": "4.2.0",
|
||||
"react": "19.2.3",
|
||||
"react-day-picker": "9.13.0",
|
||||
"react-dom": "19.2.3",
|
||||
"react": "19.2.4",
|
||||
"react-day-picker": "9.13.2",
|
||||
"react-dom": "19.2.4",
|
||||
"react-easy-sort": "1.8.0",
|
||||
"react-hook-form": "7.71.1",
|
||||
"react-icons": "5.5.0",
|
||||
"recharts": "2.15.4",
|
||||
"reodotdev": "1.0.0",
|
||||
"resend": "6.8.0",
|
||||
"semver": "7.7.3",
|
||||
"semver": "7.7.4",
|
||||
"stripe": "20.2.0",
|
||||
"swagger-ui-express": "5.0.1",
|
||||
"tailwind-merge": "3.4.0",
|
||||
"tailwind-merge": "3.4.1",
|
||||
"topojson-client": "3.1.0",
|
||||
"tw-animate-css": "1.4.0",
|
||||
"uuid": "13.0.0",
|
||||
@@ -131,7 +128,7 @@
|
||||
"ws": "8.19.0",
|
||||
"yaml": "2.8.2",
|
||||
"yargs": "18.0.0",
|
||||
"zod": "4.3.5",
|
||||
"zod": "4.3.6",
|
||||
"zod-validation-error": "5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -152,7 +149,7 @@
|
||||
"@types/nodemailer": "7.0.4",
|
||||
"@types/nprogress": "0.2.3",
|
||||
"@types/pg": "8.16.0",
|
||||
"@types/react": "19.2.7",
|
||||
"@types/react": "19.2.14",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@types/semver": "7.7.1",
|
||||
"@types/swagger-ui-express": "4.1.8",
|
||||
|
||||
@@ -56,15 +56,15 @@ Ensure drizzle-kit is installed.
|
||||
You must have a connection string in your config file, as shown above.
|
||||
|
||||
```bash
|
||||
npm run db:pg:generate
|
||||
npm run db:pg:push
|
||||
npm run db:generate
|
||||
npm run db:push
|
||||
```
|
||||
|
||||
### SQLite
|
||||
|
||||
```bash
|
||||
npm run db:sqlite:generate
|
||||
npm run db:sqlite:push
|
||||
npm run db:generate
|
||||
npm run db:push
|
||||
```
|
||||
|
||||
## Build Time
|
||||
|
||||
3
server/db/migrate.ts
Normal file
3
server/db/migrate.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { runMigrations } from "./";
|
||||
|
||||
await runMigrations();
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./driver";
|
||||
export * from "./schema/schema";
|
||||
export * from "./schema/privateSchema";
|
||||
export * from "./migrate";
|
||||
|
||||
@@ -4,7 +4,7 @@ import path from "path";
|
||||
|
||||
const migrationsFolder = path.join("server/migrations");
|
||||
|
||||
const runMigrations = async () => {
|
||||
export const runMigrations = async () => {
|
||||
console.log("Running migrations...");
|
||||
try {
|
||||
await migrate(db as any, {
|
||||
@@ -17,5 +17,3 @@ const runMigrations = async () => {
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
runMigrations();
|
||||
|
||||
@@ -142,7 +142,8 @@ export const resources = pgTable("resources", {
|
||||
}).default("forced"), // "forced" = always show, "automatic" = only when down
|
||||
maintenanceTitle: text("maintenanceTitle"),
|
||||
maintenanceMessage: text("maintenanceMessage"),
|
||||
maintenanceEstimatedTime: text("maintenanceEstimatedTime")
|
||||
maintenanceEstimatedTime: text("maintenanceEstimatedTime"),
|
||||
postAuthPath: text("postAuthPath")
|
||||
});
|
||||
|
||||
export const targets = pgTable("targets", {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./driver";
|
||||
export * from "./schema/schema";
|
||||
export * from "./schema/privateSchema";
|
||||
export * from "./migrate";
|
||||
|
||||
@@ -4,7 +4,7 @@ import path from "path";
|
||||
|
||||
const migrationsFolder = path.join("server/migrations");
|
||||
|
||||
const runMigrations = async () => {
|
||||
export const runMigrations = async () => {
|
||||
console.log("Running migrations...");
|
||||
try {
|
||||
migrate(db as any, {
|
||||
@@ -16,5 +16,3 @@ const runMigrations = async () => {
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
runMigrations();
|
||||
|
||||
@@ -162,7 +162,8 @@ export const resources = sqliteTable("resources", {
|
||||
}).default("forced"), // "forced" = always show, "automatic" = only when down
|
||||
maintenanceTitle: text("maintenanceTitle"),
|
||||
maintenanceMessage: text("maintenanceMessage"),
|
||||
maintenanceEstimatedTime: text("maintenanceEstimatedTime")
|
||||
maintenanceEstimatedTime: text("maintenanceEstimatedTime"),
|
||||
postAuthPath: text("postAuthPath")
|
||||
});
|
||||
|
||||
export const targets = sqliteTable("targets", {
|
||||
|
||||
@@ -15,10 +15,10 @@ export const sandboxLimitSet: LimitSet = {
|
||||
};
|
||||
|
||||
export const freeLimitSet: LimitSet = {
|
||||
[FeatureId.USERS]: { value: 5, description: "Starter limit" },
|
||||
[FeatureId.SITES]: { value: 5, description: "Starter limit" },
|
||||
[FeatureId.DOMAINS]: { value: 5, description: "Starter limit" },
|
||||
[FeatureId.REMOTE_EXIT_NODES]: { value: 1, description: "Starter limit" },
|
||||
[FeatureId.SITES]: { value: 5, description: "Basic limit" },
|
||||
[FeatureId.USERS]: { value: 5, description: "Basic limit" },
|
||||
[FeatureId.DOMAINS]: { value: 5, description: "Basic limit" },
|
||||
[FeatureId.REMOTE_EXIT_NODES]: { value: 1, description: "Basic limit" },
|
||||
};
|
||||
|
||||
export const tier1LimitSet: LimitSet = {
|
||||
|
||||
@@ -2,7 +2,7 @@ import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
// This is a placeholder value replaced by the build process
|
||||
export const APP_VERSION = "1.15.3";
|
||||
export const APP_VERSION = "1.15.4";
|
||||
|
||||
export const __FILENAME = fileURLToPath(import.meta.url);
|
||||
export const __DIRNAME = path.dirname(__FILENAME);
|
||||
|
||||
169
server/lib/deleteOrg.ts
Normal file
169
server/lib/deleteOrg.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import {
|
||||
clients,
|
||||
clientSiteResourcesAssociationsCache,
|
||||
clientSitesAssociationsCache,
|
||||
db,
|
||||
domains,
|
||||
olms,
|
||||
orgDomains,
|
||||
orgs,
|
||||
resources,
|
||||
sites
|
||||
} from "@server/db";
|
||||
import { newts, newtSessions } from "@server/db";
|
||||
import { eq, and, inArray, sql } from "drizzle-orm";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { sendToClient } from "#dynamic/routers/ws";
|
||||
import { deletePeer } from "@server/routers/gerbil/peers";
|
||||
import { OlmErrorCodes } from "@server/routers/olm/error";
|
||||
import { sendTerminateClient } from "@server/routers/client/terminate";
|
||||
|
||||
export type DeleteOrgByIdResult = {
|
||||
deletedNewtIds: string[];
|
||||
olmsToTerminate: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Deletes one organization and its related data. Returns ids for termination
|
||||
* messages; caller should call sendTerminationMessages with the result.
|
||||
* Throws if org not found.
|
||||
*/
|
||||
export async function deleteOrgById(
|
||||
orgId: string
|
||||
): Promise<DeleteOrgByIdResult> {
|
||||
const [org] = await db
|
||||
.select()
|
||||
.from(orgs)
|
||||
.where(eq(orgs.orgId, orgId))
|
||||
.limit(1);
|
||||
|
||||
if (!org) {
|
||||
throw createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Organization with ID ${orgId} not found`
|
||||
);
|
||||
}
|
||||
|
||||
const orgSites = await db
|
||||
.select()
|
||||
.from(sites)
|
||||
.where(eq(sites.orgId, orgId))
|
||||
.limit(1);
|
||||
|
||||
const orgClients = await db
|
||||
.select()
|
||||
.from(clients)
|
||||
.where(eq(clients.orgId, orgId));
|
||||
|
||||
const deletedNewtIds: string[] = [];
|
||||
const olmsToTerminate: string[] = [];
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
for (const site of orgSites) {
|
||||
if (site.pubKey) {
|
||||
if (site.type == "wireguard") {
|
||||
await deletePeer(site.exitNodeId!, site.pubKey);
|
||||
} else if (site.type == "newt") {
|
||||
const [deletedNewt] = await trx
|
||||
.delete(newts)
|
||||
.where(eq(newts.siteId, site.siteId))
|
||||
.returning();
|
||||
if (deletedNewt) {
|
||||
deletedNewtIds.push(deletedNewt.newtId);
|
||||
await trx
|
||||
.delete(newtSessions)
|
||||
.where(
|
||||
eq(newtSessions.newtId, deletedNewt.newtId)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
logger.info(`Deleting site ${site.siteId}`);
|
||||
await trx.delete(sites).where(eq(sites.siteId, site.siteId));
|
||||
}
|
||||
for (const client of orgClients) {
|
||||
const [olm] = await trx
|
||||
.select()
|
||||
.from(olms)
|
||||
.where(eq(olms.clientId, client.clientId))
|
||||
.limit(1);
|
||||
if (olm) {
|
||||
olmsToTerminate.push(olm.olmId);
|
||||
}
|
||||
logger.info(`Deleting client ${client.clientId}`);
|
||||
await trx
|
||||
.delete(clients)
|
||||
.where(eq(clients.clientId, client.clientId));
|
||||
await trx
|
||||
.delete(clientSiteResourcesAssociationsCache)
|
||||
.where(
|
||||
eq(
|
||||
clientSiteResourcesAssociationsCache.clientId,
|
||||
client.clientId
|
||||
)
|
||||
);
|
||||
await trx
|
||||
.delete(clientSitesAssociationsCache)
|
||||
.where(
|
||||
eq(clientSitesAssociationsCache.clientId, client.clientId)
|
||||
);
|
||||
}
|
||||
const allOrgDomains = await trx
|
||||
.select()
|
||||
.from(orgDomains)
|
||||
.innerJoin(domains, eq(domains.domainId, orgDomains.domainId))
|
||||
.where(
|
||||
and(
|
||||
eq(orgDomains.orgId, orgId),
|
||||
eq(domains.configManaged, false)
|
||||
)
|
||||
);
|
||||
const domainIdsToDelete: string[] = [];
|
||||
for (const orgDomain of allOrgDomains) {
|
||||
const domainId = orgDomain.domains.domainId;
|
||||
const orgCount = await trx
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(orgDomains)
|
||||
.where(eq(orgDomains.domainId, domainId));
|
||||
if (orgCount[0].count === 1) {
|
||||
domainIdsToDelete.push(domainId);
|
||||
}
|
||||
}
|
||||
if (domainIdsToDelete.length > 0) {
|
||||
await trx
|
||||
.delete(domains)
|
||||
.where(inArray(domains.domainId, domainIdsToDelete));
|
||||
}
|
||||
await trx.delete(resources).where(eq(resources.orgId, orgId));
|
||||
await trx.delete(orgs).where(eq(orgs.orgId, orgId));
|
||||
});
|
||||
|
||||
return { deletedNewtIds, olmsToTerminate };
|
||||
}
|
||||
|
||||
export function sendTerminationMessages(result: DeleteOrgByIdResult): void {
|
||||
for (const newtId of result.deletedNewtIds) {
|
||||
sendToClient(newtId, { type: `newt/wg/terminate`, data: {} }).catch(
|
||||
(error) => {
|
||||
logger.error(
|
||||
"Failed to send termination message to newt:",
|
||||
error
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
for (const olmId of result.olmsToTerminate) {
|
||||
sendTerminateClient(
|
||||
0,
|
||||
OlmErrorCodes.TERMINATED_REKEYED,
|
||||
olmId
|
||||
).catch((error) => {
|
||||
logger.error(
|
||||
"Failed to send termination message to olm:",
|
||||
error
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
18
server/lib/normalizePostAuthPath.ts
Normal file
18
server/lib/normalizePostAuthPath.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Normalizes a post-authentication path for safe use when building redirect URLs.
|
||||
* Returns a path that starts with / and does not allow open redirects (no //, no :).
|
||||
*/
|
||||
export function normalizePostAuthPath(path: string | null | undefined): string | null {
|
||||
if (path == null || typeof path !== "string") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = path.trim();
|
||||
if (trimmed === "") {
|
||||
return null;
|
||||
}
|
||||
// Reject protocol-relative (//) or scheme (:) to avoid open redirect
|
||||
if (trimmed.includes("//") || trimmed.includes(":")) {
|
||||
return null;
|
||||
}
|
||||
return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
||||
}
|
||||
@@ -18,6 +18,113 @@ import logger from "@server/logger";
|
||||
import { db, idp, idpOrg, loginPage, loginPageBranding, loginPageBrandingOrg, loginPageOrg, orgs, resources, roles } from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
/**
|
||||
* Get the maximum allowed retention days for a given tier
|
||||
* Returns null for enterprise tier (unlimited)
|
||||
*/
|
||||
function getMaxRetentionDaysForTier(tier: Tier | null): number | null {
|
||||
if (!tier) {
|
||||
return 3; // Free tier
|
||||
}
|
||||
|
||||
switch (tier) {
|
||||
case "tier1":
|
||||
return 7;
|
||||
case "tier2":
|
||||
return 30;
|
||||
case "tier3":
|
||||
return 90;
|
||||
case "enterprise":
|
||||
return null; // No limit
|
||||
default:
|
||||
return 3; // Default to free tier limit
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cap retention days to the maximum allowed for the given tier
|
||||
*/
|
||||
async function capRetentionDays(
|
||||
orgId: string,
|
||||
tier: Tier | null
|
||||
): Promise<void> {
|
||||
const maxRetentionDays = getMaxRetentionDaysForTier(tier);
|
||||
|
||||
// If there's no limit (enterprise tier), no capping needed
|
||||
if (maxRetentionDays === null) {
|
||||
logger.debug(
|
||||
`No retention day limit for org ${orgId} on tier ${tier || "free"}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get current org settings
|
||||
const [org] = await db
|
||||
.select()
|
||||
.from(orgs)
|
||||
.where(eq(orgs.orgId, orgId));
|
||||
|
||||
if (!org) {
|
||||
logger.warn(`Org ${orgId} not found when capping retention days`);
|
||||
return;
|
||||
}
|
||||
|
||||
const updates: Partial<typeof orgs.$inferInsert> = {};
|
||||
let needsUpdate = false;
|
||||
|
||||
// Cap request log retention if it exceeds the limit
|
||||
if (
|
||||
org.settingsLogRetentionDaysRequest !== null &&
|
||||
org.settingsLogRetentionDaysRequest > maxRetentionDays
|
||||
) {
|
||||
updates.settingsLogRetentionDaysRequest = maxRetentionDays;
|
||||
needsUpdate = true;
|
||||
logger.info(
|
||||
`Capping request log retention from ${org.settingsLogRetentionDaysRequest} to ${maxRetentionDays} days for org ${orgId}`
|
||||
);
|
||||
}
|
||||
|
||||
// Cap access log retention if it exceeds the limit
|
||||
if (
|
||||
org.settingsLogRetentionDaysAccess !== null &&
|
||||
org.settingsLogRetentionDaysAccess > maxRetentionDays
|
||||
) {
|
||||
updates.settingsLogRetentionDaysAccess = maxRetentionDays;
|
||||
needsUpdate = true;
|
||||
logger.info(
|
||||
`Capping access log retention from ${org.settingsLogRetentionDaysAccess} to ${maxRetentionDays} days for org ${orgId}`
|
||||
);
|
||||
}
|
||||
|
||||
// Cap action log retention if it exceeds the limit
|
||||
if (
|
||||
org.settingsLogRetentionDaysAction !== null &&
|
||||
org.settingsLogRetentionDaysAction > maxRetentionDays
|
||||
) {
|
||||
updates.settingsLogRetentionDaysAction = maxRetentionDays;
|
||||
needsUpdate = true;
|
||||
logger.info(
|
||||
`Capping action log retention from ${org.settingsLogRetentionDaysAction} to ${maxRetentionDays} days for org ${orgId}`
|
||||
);
|
||||
}
|
||||
|
||||
// Apply updates if needed
|
||||
if (needsUpdate) {
|
||||
await db
|
||||
.update(orgs)
|
||||
.set(updates)
|
||||
.where(eq(orgs.orgId, orgId));
|
||||
|
||||
logger.info(
|
||||
`Successfully capped retention days for org ${orgId} to max ${maxRetentionDays} days`
|
||||
);
|
||||
} else {
|
||||
logger.debug(
|
||||
`No retention day capping needed for org ${orgId}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleTierChange(
|
||||
orgId: string,
|
||||
newTier: SubscriptionType | null,
|
||||
@@ -40,6 +147,9 @@ export async function handleTierChange(
|
||||
logger.info(
|
||||
`Org ${orgId} is reverting to free tier, disabling all paid features`
|
||||
);
|
||||
// Cap retention days to free tier limits
|
||||
await capRetentionDays(orgId, null);
|
||||
|
||||
// Disable all features in the tier matrix
|
||||
for (const [featureKey] of Object.entries(tierMatrix)) {
|
||||
const feature = featureKey as TierFeature;
|
||||
@@ -57,6 +167,9 @@ export async function handleTierChange(
|
||||
// Get the tier (cast as Tier since we've ruled out "license" and null)
|
||||
const tier = newTier as Tier;
|
||||
|
||||
// Cap retention days to the new tier's limits
|
||||
await capRetentionDays(orgId, tier);
|
||||
|
||||
// Check each feature in the tier matrix
|
||||
for (const [featureKey, allowedTiers] of Object.entries(tierMatrix)) {
|
||||
const feature = featureKey as TierFeature;
|
||||
|
||||
@@ -113,7 +113,7 @@ export async function generateNewEnterpriseLicense(
|
||||
}
|
||||
|
||||
const tier = licenseData.tier === "big_license" ? LicenseId.BIG_LICENSE : LicenseId.SMALL_LICENSE;
|
||||
const tierPrice = getLicensePriceSet()[tier]
|
||||
const tierPrice = getLicensePriceSet()[tier];
|
||||
|
||||
const session = await stripe!.checkout.sessions.create({
|
||||
client_reference_id: keyId.toString(),
|
||||
|
||||
@@ -28,6 +28,7 @@ import { CreateOrgIdpResponse } from "@server/routers/orgIdp/types";
|
||||
import { isSubscribed } from "#private/lib/isSubscribed";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import privateConfig from "#private/lib/config";
|
||||
import { build } from "@server/build";
|
||||
|
||||
const paramsSchema = z.strictObject({ orgId: z.string().nonempty() });
|
||||
|
||||
@@ -122,12 +123,14 @@ export async function createOrgOidcIdp(
|
||||
|
||||
let { autoProvision } = parsedBody.data;
|
||||
|
||||
const subscribed = await isSubscribed(
|
||||
orgId,
|
||||
tierMatrix.deviceApprovals
|
||||
);
|
||||
if (!subscribed) {
|
||||
autoProvision = false;
|
||||
if (build == "saas") { // this is not paywalled with a ee license because this whole endpoint is restricted
|
||||
const subscribed = await isSubscribed(
|
||||
orgId,
|
||||
tierMatrix.deviceApprovals
|
||||
);
|
||||
if (!subscribed) {
|
||||
autoProvision = false;
|
||||
}
|
||||
}
|
||||
|
||||
const key = config.getRawConfig().server.secret!;
|
||||
|
||||
@@ -27,6 +27,7 @@ import config from "@server/lib/config";
|
||||
import { isSubscribed } from "#private/lib/isSubscribed";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import privateConfig from "#private/lib/config";
|
||||
import { build } from "@server/build";
|
||||
|
||||
const paramsSchema = z
|
||||
.object({
|
||||
@@ -127,12 +128,15 @@ export async function updateOrgOidcIdp(
|
||||
|
||||
let { autoProvision } = parsedBody.data;
|
||||
|
||||
const subscribed = await isSubscribed(
|
||||
orgId,
|
||||
tierMatrix.deviceApprovals
|
||||
);
|
||||
if (!subscribed) {
|
||||
autoProvision = false;
|
||||
if (build == "saas") {
|
||||
// this is not paywalled with a ee license because this whole endpoint is restricted
|
||||
const subscribed = await isSubscribed(
|
||||
orgId,
|
||||
tierMatrix.deviceApprovals
|
||||
);
|
||||
if (!subscribed) {
|
||||
autoProvision = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if IDP exists and is of type OIDC
|
||||
|
||||
228
server/routers/auth/deleteMyAccount.ts
Normal file
228
server/routers/auth/deleteMyAccount.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db, orgs, userOrgs, users } from "@server/db";
|
||||
import { eq, and, inArray } 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 { verifySession } from "@server/auth/sessions/verifySession";
|
||||
import {
|
||||
invalidateSession,
|
||||
createBlankSessionTokenCookie
|
||||
} from "@server/auth/sessions/app";
|
||||
import { verifyPassword } from "@server/auth/password";
|
||||
import { verifyTotpCode } from "@server/auth/totp";
|
||||
import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs";
|
||||
import {
|
||||
deleteOrgById,
|
||||
sendTerminationMessages
|
||||
} from "@server/lib/deleteOrg";
|
||||
import { UserType } from "@server/types/UserTypes";
|
||||
|
||||
const deleteMyAccountBody = z.strictObject({
|
||||
password: z.string().optional(),
|
||||
code: z.string().optional()
|
||||
});
|
||||
|
||||
export type DeleteMyAccountPreviewResponse = {
|
||||
preview: true;
|
||||
orgs: { orgId: string; name: string }[];
|
||||
twoFactorEnabled: boolean;
|
||||
};
|
||||
|
||||
export type DeleteMyAccountCodeRequestedResponse = {
|
||||
codeRequested: true;
|
||||
};
|
||||
|
||||
export type DeleteMyAccountSuccessResponse = {
|
||||
success: true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Self-service account deletion (saas only). Returns preview when no password;
|
||||
* requires password and optional 2FA code to perform deletion. Uses shared
|
||||
* deleteOrgById for each owned org (delete-my-account may delete multiple orgs).
|
||||
*/
|
||||
export async function deleteMyAccount(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const { user, session } = await verifySession(req);
|
||||
if (!user || !session) {
|
||||
return next(
|
||||
createHttpError(HttpCode.UNAUTHORIZED, "Not authenticated")
|
||||
);
|
||||
}
|
||||
|
||||
if (user.serverAdmin) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Server admins cannot delete their account this way"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (user.type !== UserType.Internal) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Account deletion with password is only supported for internal users"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const parsed = deleteMyAccountBody.safeParse(req.body ?? {});
|
||||
if (!parsed.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsed.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
const { password, code } = parsed.data;
|
||||
|
||||
const userId = user.userId;
|
||||
|
||||
const ownedOrgsRows = await db
|
||||
.select({
|
||||
orgId: userOrgs.orgId
|
||||
})
|
||||
.from(userOrgs)
|
||||
.where(
|
||||
and(
|
||||
eq(userOrgs.userId, userId),
|
||||
eq(userOrgs.isOwner, true)
|
||||
)
|
||||
);
|
||||
|
||||
const orgIds = ownedOrgsRows.map((r) => r.orgId);
|
||||
|
||||
if (!password) {
|
||||
const orgsWithNames =
|
||||
orgIds.length > 0
|
||||
? await db
|
||||
.select({
|
||||
orgId: orgs.orgId,
|
||||
name: orgs.name
|
||||
})
|
||||
.from(orgs)
|
||||
.where(inArray(orgs.orgId, orgIds))
|
||||
: [];
|
||||
return response<DeleteMyAccountPreviewResponse>(res, {
|
||||
data: {
|
||||
preview: true,
|
||||
orgs: orgsWithNames.map((o) => ({
|
||||
orgId: o.orgId,
|
||||
name: o.name ?? ""
|
||||
})),
|
||||
twoFactorEnabled: user.twoFactorEnabled ?? false
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Preview",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
}
|
||||
|
||||
const validPassword = await verifyPassword(
|
||||
password,
|
||||
user.passwordHash!
|
||||
);
|
||||
if (!validPassword) {
|
||||
return next(
|
||||
createHttpError(HttpCode.UNAUTHORIZED, "Invalid password")
|
||||
);
|
||||
}
|
||||
|
||||
if (user.twoFactorEnabled) {
|
||||
if (!code) {
|
||||
return response<DeleteMyAccountCodeRequestedResponse>(res, {
|
||||
data: { codeRequested: true },
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Two-factor code required",
|
||||
status: HttpCode.ACCEPTED
|
||||
});
|
||||
}
|
||||
const validOTP = await verifyTotpCode(
|
||||
code,
|
||||
user.twoFactorSecret!,
|
||||
user.userId
|
||||
);
|
||||
if (!validOTP) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"The two-factor code you entered is incorrect"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const allDeletedNewtIds: string[] = [];
|
||||
const allOlmsToTerminate: string[] = [];
|
||||
|
||||
for (const row of ownedOrgsRows) {
|
||||
try {
|
||||
const result = await deleteOrgById(row.orgId);
|
||||
allDeletedNewtIds.push(...result.deletedNewtIds);
|
||||
allOlmsToTerminate.push(...result.olmsToTerminate);
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
`Failed to delete org ${row.orgId} during account deletion`,
|
||||
err
|
||||
);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Failed to delete organization"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
sendTerminationMessages({
|
||||
deletedNewtIds: allDeletedNewtIds,
|
||||
olmsToTerminate: allOlmsToTerminate
|
||||
});
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
await trx.delete(users).where(eq(users.userId, userId));
|
||||
await calculateUserClientsForOrgs(userId, trx);
|
||||
});
|
||||
|
||||
try {
|
||||
await invalidateSession(session.sessionId);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
"Failed to invalidate session after account deletion",
|
||||
error
|
||||
);
|
||||
}
|
||||
|
||||
const isSecure = req.protocol === "https";
|
||||
res.setHeader("Set-Cookie", createBlankSessionTokenCookie(isSecure));
|
||||
|
||||
return response<DeleteMyAccountSuccessResponse>(res, {
|
||||
data: { success: true },
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Account deleted successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"An error occurred"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -17,4 +17,5 @@ export * from "./securityKey";
|
||||
export * from "./startDeviceWebAuth";
|
||||
export * from "./verifyDeviceWebAuth";
|
||||
export * from "./pollDeviceWebAuth";
|
||||
export * from "./lookupUser";
|
||||
export * from "./lookupUser";
|
||||
export * from "./deleteMyAccount";
|
||||
@@ -797,7 +797,7 @@ async function notAllowed(
|
||||
) {
|
||||
let loginPage: LoginPage | null = null;
|
||||
if (orgId) {
|
||||
const subscribed = await isSubscribed(
|
||||
const subscribed = await isSubscribed( // this is fine because the org login page is only a saas feature
|
||||
orgId,
|
||||
tierMatrix.loginPageDomain
|
||||
);
|
||||
@@ -854,7 +854,7 @@ async function headerAuthChallenged(
|
||||
) {
|
||||
let loginPage: LoginPage | null = null;
|
||||
if (orgId) {
|
||||
const subscribed = await isSubscribed(orgId, tierMatrix.loginPageDomain);
|
||||
const subscribed = await isSubscribed(orgId, tierMatrix.loginPageDomain); // this is fine because the org login page is only a saas feature
|
||||
if (subscribed) {
|
||||
loginPage = await getOrgLoginPage(orgId);
|
||||
}
|
||||
|
||||
@@ -1164,6 +1164,7 @@ authRouter.post(
|
||||
auth.login
|
||||
);
|
||||
authRouter.post("/logout", auth.logout);
|
||||
authRouter.post("/delete-my-account", auth.deleteMyAccount);
|
||||
authRouter.post(
|
||||
"/lookup-user",
|
||||
rateLimit({
|
||||
|
||||
@@ -1,28 +1,12 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
clients,
|
||||
clientSiteResourcesAssociationsCache,
|
||||
clientSitesAssociationsCache,
|
||||
db,
|
||||
domains,
|
||||
olms,
|
||||
orgDomains,
|
||||
resources
|
||||
} from "@server/db";
|
||||
import { newts, newtSessions, orgs, sites, userActions } from "@server/db";
|
||||
import { eq, and, inArray, sql } from "drizzle-orm";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { sendToClient } from "#dynamic/routers/ws";
|
||||
import { deletePeer } from "../gerbil/peers";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { OlmErrorCodes } from "../olm/error";
|
||||
import { sendTerminateClient } from "../client/terminate";
|
||||
import { deleteOrgById, sendTerminationMessages } from "@server/lib/deleteOrg";
|
||||
|
||||
const deleteOrgSchema = z.strictObject({
|
||||
orgId: z.string()
|
||||
@@ -56,170 +40,9 @@ export async function deleteOrg(
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { orgId } = parsedParams.data;
|
||||
|
||||
const [org] = await db
|
||||
.select()
|
||||
.from(orgs)
|
||||
.where(eq(orgs.orgId, orgId))
|
||||
.limit(1);
|
||||
|
||||
if (!org) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Organization with ID ${orgId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
// we need to handle deleting each site
|
||||
const orgSites = await db
|
||||
.select()
|
||||
.from(sites)
|
||||
.where(eq(sites.orgId, orgId))
|
||||
.limit(1);
|
||||
|
||||
const orgClients = await db
|
||||
.select()
|
||||
.from(clients)
|
||||
.where(eq(clients.orgId, orgId));
|
||||
|
||||
const deletedNewtIds: string[] = [];
|
||||
const olmsToTerminate: string[] = [];
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
for (const site of orgSites) {
|
||||
if (site.pubKey) {
|
||||
if (site.type == "wireguard") {
|
||||
await deletePeer(site.exitNodeId!, site.pubKey);
|
||||
} else if (site.type == "newt") {
|
||||
// get the newt on the site by querying the newt table for siteId
|
||||
const [deletedNewt] = await trx
|
||||
.delete(newts)
|
||||
.where(eq(newts.siteId, site.siteId))
|
||||
.returning();
|
||||
if (deletedNewt) {
|
||||
deletedNewtIds.push(deletedNewt.newtId);
|
||||
|
||||
// delete all of the sessions for the newt
|
||||
await trx
|
||||
.delete(newtSessions)
|
||||
.where(
|
||||
eq(newtSessions.newtId, deletedNewt.newtId)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Deleting site ${site.siteId}`);
|
||||
await trx.delete(sites).where(eq(sites.siteId, site.siteId));
|
||||
}
|
||||
for (const client of orgClients) {
|
||||
const [olm] = await trx
|
||||
.select()
|
||||
.from(olms)
|
||||
.where(eq(olms.clientId, client.clientId))
|
||||
.limit(1);
|
||||
|
||||
if (olm) {
|
||||
olmsToTerminate.push(olm.olmId);
|
||||
}
|
||||
|
||||
logger.info(`Deleting client ${client.clientId}`);
|
||||
await trx
|
||||
.delete(clients)
|
||||
.where(eq(clients.clientId, client.clientId));
|
||||
|
||||
// also delete the associations
|
||||
await trx
|
||||
.delete(clientSiteResourcesAssociationsCache)
|
||||
.where(
|
||||
eq(
|
||||
clientSiteResourcesAssociationsCache.clientId,
|
||||
client.clientId
|
||||
)
|
||||
);
|
||||
|
||||
await trx
|
||||
.delete(clientSitesAssociationsCache)
|
||||
.where(
|
||||
eq(
|
||||
clientSitesAssociationsCache.clientId,
|
||||
client.clientId
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const allOrgDomains = await trx
|
||||
.select()
|
||||
.from(orgDomains)
|
||||
.innerJoin(domains, eq(domains.domainId, orgDomains.domainId))
|
||||
.where(
|
||||
and(
|
||||
eq(orgDomains.orgId, orgId),
|
||||
eq(domains.configManaged, false)
|
||||
)
|
||||
);
|
||||
|
||||
// For each domain, check if it belongs to multiple organizations
|
||||
const domainIdsToDelete: string[] = [];
|
||||
for (const orgDomain of allOrgDomains) {
|
||||
const domainId = orgDomain.domains.domainId;
|
||||
|
||||
// Count how many organizations this domain belongs to
|
||||
const orgCount = await trx
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(orgDomains)
|
||||
.where(eq(orgDomains.domainId, domainId));
|
||||
|
||||
// Only delete the domain if it belongs to exactly 1 organization (the one being deleted)
|
||||
if (orgCount[0].count === 1) {
|
||||
domainIdsToDelete.push(domainId);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete domains that belong exclusively to this organization
|
||||
if (domainIdsToDelete.length > 0) {
|
||||
await trx
|
||||
.delete(domains)
|
||||
.where(inArray(domains.domainId, domainIdsToDelete));
|
||||
}
|
||||
|
||||
// Delete resources
|
||||
await trx.delete(resources).where(eq(resources.orgId, orgId));
|
||||
|
||||
await trx.delete(orgs).where(eq(orgs.orgId, orgId));
|
||||
});
|
||||
|
||||
// Send termination messages outside of transaction to prevent blocking
|
||||
for (const newtId of deletedNewtIds) {
|
||||
const payload = {
|
||||
type: `newt/wg/terminate`,
|
||||
data: {}
|
||||
};
|
||||
// Don't await this to prevent blocking the response
|
||||
sendToClient(newtId, payload).catch((error) => {
|
||||
logger.error(
|
||||
"Failed to send termination message to newt:",
|
||||
error
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
for (const olmId of olmsToTerminate) {
|
||||
sendTerminateClient(
|
||||
0, // clientId not needed since we're passing olmId
|
||||
OlmErrorCodes.TERMINATED_REKEYED,
|
||||
olmId
|
||||
).catch((error) => {
|
||||
logger.error(
|
||||
"Failed to send termination message to olm:",
|
||||
error
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const result = await deleteOrgById(orgId);
|
||||
sendTerminationMessages(result);
|
||||
return response(res, {
|
||||
data: null,
|
||||
success: true,
|
||||
@@ -228,6 +51,9 @@ export async function deleteOrg(
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
if (createHttpError.isHttpError(error)) {
|
||||
return next(error);
|
||||
}
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(
|
||||
|
||||
@@ -14,6 +14,7 @@ import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToke
|
||||
import config from "@server/lib/config";
|
||||
import stoi from "@server/lib/stoi";
|
||||
import { logAccessAudit } from "#dynamic/lib/logAccessAudit";
|
||||
import { normalizePostAuthPath } from "@server/lib/normalizePostAuthPath";
|
||||
|
||||
const authWithAccessTokenBodySchema = z.strictObject({
|
||||
accessToken: z.string(),
|
||||
@@ -164,10 +165,16 @@ export async function authWithAccessToken(
|
||||
requestIp: req.ip
|
||||
});
|
||||
|
||||
let redirectUrl = `${resource.ssl ? "https" : "http"}://${resource.fullDomain}`;
|
||||
const postAuthPath = normalizePostAuthPath(resource.postAuthPath);
|
||||
if (postAuthPath) {
|
||||
redirectUrl = redirectUrl + postAuthPath;
|
||||
}
|
||||
|
||||
return response<AuthWithAccessTokenResponse>(res, {
|
||||
data: {
|
||||
session: token,
|
||||
redirectUrl: `${resource.ssl ? "https" : "http"}://${resource.fullDomain}`
|
||||
redirectUrl
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
|
||||
@@ -36,7 +36,8 @@ const createHttpResourceSchema = z
|
||||
http: z.boolean(),
|
||||
protocol: z.enum(["tcp", "udp"]),
|
||||
domainId: z.string(),
|
||||
stickySession: z.boolean().optional()
|
||||
stickySession: z.boolean().optional(),
|
||||
postAuthPath: z.string().nullable().optional()
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
@@ -188,7 +189,7 @@ async function createHttpResource(
|
||||
);
|
||||
}
|
||||
|
||||
const { name, domainId } = parsedBody.data;
|
||||
const { name, domainId, postAuthPath } = parsedBody.data;
|
||||
const subdomain = parsedBody.data.subdomain;
|
||||
const stickySession = parsedBody.data.stickySession;
|
||||
|
||||
@@ -255,7 +256,8 @@ async function createHttpResource(
|
||||
http: true,
|
||||
protocol: "tcp",
|
||||
ssl: true,
|
||||
stickySession: stickySession
|
||||
stickySession: stickySession,
|
||||
postAuthPath: postAuthPath
|
||||
})
|
||||
.returning();
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ export type GetResourceAuthInfoResponse = {
|
||||
whitelist: boolean;
|
||||
skipToIdpId: number | null;
|
||||
orgId: string;
|
||||
postAuthPath: string | null;
|
||||
};
|
||||
|
||||
export async function getResourceAuthInfo(
|
||||
@@ -147,7 +148,8 @@ export async function getResourceAuthInfo(
|
||||
url,
|
||||
whitelist: resource.emailWhitelistEnabled,
|
||||
skipToIdpId: resource.skipToIdpId,
|
||||
orgId: resource.orgId
|
||||
orgId: resource.orgId,
|
||||
postAuthPath: resource.postAuthPath ?? null
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
|
||||
@@ -55,7 +55,8 @@ const updateHttpResourceBodySchema = z
|
||||
maintenanceModeType: z.enum(["forced", "automatic"]).optional(),
|
||||
maintenanceTitle: z.string().max(255).nullable().optional(),
|
||||
maintenanceMessage: z.string().max(2000).nullable().optional(),
|
||||
maintenanceEstimatedTime: z.string().max(100).nullable().optional()
|
||||
maintenanceEstimatedTime: z.string().max(100).nullable().optional(),
|
||||
postAuthPath: z.string().nullable().optional()
|
||||
})
|
||||
.refine((data) => Object.keys(data).length > 0, {
|
||||
error: "At least one field must be provided for update"
|
||||
|
||||
1
server/setup/.gitignore
vendored
Normal file
1
server/setup/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
migrations.ts
|
||||
@@ -1,162 +0,0 @@
|
||||
#! /usr/bin/env node
|
||||
import { migrate } from "drizzle-orm/node-postgres/migrator";
|
||||
import { db } from "../db/pg";
|
||||
import semver from "semver";
|
||||
import { versionMigrations } from "../db/pg";
|
||||
import { __DIRNAME, APP_VERSION } from "@server/lib/consts";
|
||||
import path from "path";
|
||||
import m1 from "./scriptsPg/1.6.0";
|
||||
import m2 from "./scriptsPg/1.7.0";
|
||||
import m3 from "./scriptsPg/1.8.0";
|
||||
import m4 from "./scriptsPg/1.9.0";
|
||||
import m5 from "./scriptsPg/1.10.0";
|
||||
import m6 from "./scriptsPg/1.10.2";
|
||||
import m7 from "./scriptsPg/1.11.0";
|
||||
import m8 from "./scriptsPg/1.11.1";
|
||||
import m9 from "./scriptsPg/1.12.0";
|
||||
import m10 from "./scriptsPg/1.13.0";
|
||||
import m11 from "./scriptsPg/1.14.0";
|
||||
import m12 from "./scriptsPg/1.15.0";
|
||||
|
||||
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
|
||||
// EXCEPT FOR THE DATABASE AND THE SCHEMA
|
||||
|
||||
// Define the migration list with versions and their corresponding functions
|
||||
const migrations = [
|
||||
{ version: "1.6.0", run: m1 },
|
||||
{ version: "1.7.0", run: m2 },
|
||||
{ version: "1.8.0", run: m3 },
|
||||
{ version: "1.9.0", run: m4 },
|
||||
{ version: "1.10.0", run: m5 },
|
||||
{ version: "1.10.2", run: m6 },
|
||||
{ version: "1.11.0", run: m7 },
|
||||
{ version: "1.11.1", run: m8 },
|
||||
{ version: "1.12.0", run: m9 },
|
||||
{ version: "1.13.0", run: m10 },
|
||||
{ version: "1.14.0", run: m11 },
|
||||
{ version: "1.15.0", run: m12 }
|
||||
// Add new migrations here as they are created
|
||||
] as {
|
||||
version: string;
|
||||
run: () => Promise<void>;
|
||||
}[];
|
||||
|
||||
await run();
|
||||
|
||||
async function run() {
|
||||
// run the migrations
|
||||
await runMigrations();
|
||||
}
|
||||
|
||||
export async function runMigrations() {
|
||||
if (process.env.DISABLE_MIGRATIONS) {
|
||||
console.log("Migrations are disabled. Skipping...");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const appVersion = APP_VERSION;
|
||||
|
||||
// determine if the migrations table exists
|
||||
const exists = await db
|
||||
.select()
|
||||
.from(versionMigrations)
|
||||
.limit(1)
|
||||
.execute()
|
||||
.then((res) => res.length > 0)
|
||||
.catch(() => false);
|
||||
|
||||
if (exists) {
|
||||
console.log("Migrations table exists, running scripts...");
|
||||
await executeScripts();
|
||||
} else {
|
||||
console.log("Migrations table does not exist, creating it...");
|
||||
console.log("Running migrations...");
|
||||
try {
|
||||
await migrate(db, {
|
||||
migrationsFolder: path.join(__DIRNAME, "init") // put here during the docker build
|
||||
});
|
||||
console.log("Migrations completed successfully.");
|
||||
} catch (error) {
|
||||
console.error("Error running migrations:", error);
|
||||
}
|
||||
|
||||
await db
|
||||
.insert(versionMigrations)
|
||||
.values({
|
||||
version: appVersion,
|
||||
executedAt: Date.now()
|
||||
})
|
||||
.execute();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error running migrations:", e);
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, 1000 * 60 * 60 * 24 * 1)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function executeScripts() {
|
||||
try {
|
||||
// Get the last executed version from the database
|
||||
const lastExecuted = await db.select().from(versionMigrations);
|
||||
|
||||
// Filter and sort migrations
|
||||
const pendingMigrations = lastExecuted
|
||||
.map((m) => m)
|
||||
.sort((a, b) => semver.compare(b.version, a.version));
|
||||
const startVersion = pendingMigrations[0]?.version ?? "0.0.0";
|
||||
console.log(`Starting migrations from version ${startVersion}`);
|
||||
|
||||
const migrationsToRun = migrations.filter((migration) =>
|
||||
semver.gt(migration.version, startVersion)
|
||||
);
|
||||
|
||||
console.log(
|
||||
"Migrations to run:",
|
||||
migrationsToRun.map((m) => m.version).join(", ")
|
||||
);
|
||||
|
||||
// Run migrations in order
|
||||
for (const migration of migrationsToRun) {
|
||||
console.log(`Running migration ${migration.version}`);
|
||||
|
||||
try {
|
||||
await migration.run();
|
||||
|
||||
// Update version in database
|
||||
await db
|
||||
.insert(versionMigrations)
|
||||
.values({
|
||||
version: migration.version,
|
||||
executedAt: Date.now()
|
||||
})
|
||||
.execute();
|
||||
|
||||
console.log(
|
||||
`Successfully completed migration ${migration.version}`
|
||||
);
|
||||
} catch (e) {
|
||||
if (
|
||||
e instanceof Error &&
|
||||
typeof (e as any).code === "string" &&
|
||||
(e as any).code === "23505"
|
||||
) {
|
||||
console.error("Migration has already run! Skipping...");
|
||||
continue; // or return, depending on context
|
||||
}
|
||||
|
||||
console.error(
|
||||
`Failed to run migration ${migration.version}:`,
|
||||
e
|
||||
);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
console.log("All migrations completed successfully");
|
||||
} catch (error) {
|
||||
console.error("Migration process failed:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import m10 from "./scriptsPg/1.13.0";
|
||||
import m11 from "./scriptsPg/1.14.0";
|
||||
import m12 from "./scriptsPg/1.15.0";
|
||||
import m13 from "./scriptsPg/1.15.3";
|
||||
import m14 from "./scriptsPg/1.15.4";
|
||||
|
||||
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
|
||||
// EXCEPT FOR THE DATABASE AND THE SCHEMA
|
||||
@@ -36,7 +37,8 @@ const migrations = [
|
||||
{ version: "1.13.0", run: m10 },
|
||||
{ version: "1.14.0", run: m11 },
|
||||
{ version: "1.15.0", run: m12 },
|
||||
{ version: "1.15.3", run: m13 }
|
||||
{ version: "1.15.3", run: m13 },
|
||||
{ version: "1.15.4", run: m14 }
|
||||
// Add new migrations here as they are created
|
||||
] as {
|
||||
version: string;
|
||||
|
||||
@@ -36,6 +36,7 @@ import m31 from "./scriptsSqlite/1.13.0";
|
||||
import m32 from "./scriptsSqlite/1.14.0";
|
||||
import m33 from "./scriptsSqlite/1.15.0";
|
||||
import m34 from "./scriptsSqlite/1.15.3";
|
||||
import m35 from "./scriptsSqlite/1.15.4";
|
||||
|
||||
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
|
||||
// EXCEPT FOR THE DATABASE AND THE SCHEMA
|
||||
@@ -70,7 +71,8 @@ const migrations = [
|
||||
{ version: "1.13.0", run: m31 },
|
||||
{ version: "1.14.0", run: m32 },
|
||||
{ version: "1.15.0", run: m33 },
|
||||
{ version: "1.15.3", run: m34 }
|
||||
{ version: "1.15.3", run: m34 },
|
||||
{ version: "1.15.4", run: m35 }
|
||||
// Add new migrations here as they are created
|
||||
] as const;
|
||||
|
||||
|
||||
27
server/setup/scriptsPg/1.15.4.ts
Normal file
27
server/setup/scriptsPg/1.15.4.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { db } from "@server/db/pg/driver";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { __DIRNAME } from "@server/lib/consts";
|
||||
|
||||
const version = "1.15.4";
|
||||
|
||||
export default async function migration() {
|
||||
console.log(`Running setup script ${version}...`);
|
||||
|
||||
try {
|
||||
await db.execute(sql`BEGIN`);
|
||||
|
||||
await db.execute(
|
||||
sql`ALTER TABLE "resources" ADD COLUMN "postAuthPath" text;`
|
||||
);
|
||||
|
||||
await db.execute(sql`COMMIT`);
|
||||
console.log("Migrated database");
|
||||
} catch (e) {
|
||||
await db.execute(sql`ROLLBACK`);
|
||||
console.log("Unable to migrate database");
|
||||
console.log(e);
|
||||
throw e;
|
||||
}
|
||||
|
||||
console.log(`${version} migration complete`);
|
||||
}
|
||||
27
server/setup/scriptsSqlite/1.15.4.ts
Normal file
27
server/setup/scriptsSqlite/1.15.4.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { __DIRNAME, APP_PATH } from "@server/lib/consts";
|
||||
import Database from "better-sqlite3";
|
||||
import path from "path";
|
||||
|
||||
const version = "1.15.4";
|
||||
|
||||
export default async function migration() {
|
||||
console.log(`Running setup script ${version}...`);
|
||||
|
||||
const location = path.join(APP_PATH, "db", "db.sqlite");
|
||||
const db = new Database(location);
|
||||
|
||||
try {
|
||||
db.transaction(() => {
|
||||
db.prepare(
|
||||
`ALTER TABLE 'resources' ADD 'postAuthPath' text;`
|
||||
).run();
|
||||
})();
|
||||
|
||||
console.log(`Migrated database`);
|
||||
} catch (e) {
|
||||
console.log("Failed to migrate db:", e);
|
||||
throw e;
|
||||
}
|
||||
|
||||
console.log(`${version} migration complete`);
|
||||
}
|
||||
@@ -61,7 +61,7 @@ import {
|
||||
import { FeatureId } from "@server/lib/billing/features";
|
||||
|
||||
// Plan tier definitions matching the mockup
|
||||
type PlanId = "starter" | "home" | "team" | "business" | "enterprise";
|
||||
type PlanId = "basic" | "home" | "team" | "business" | "enterprise";
|
||||
|
||||
type PlanOption = {
|
||||
id: PlanId;
|
||||
@@ -73,8 +73,8 @@ type PlanOption = {
|
||||
|
||||
const planOptions: PlanOption[] = [
|
||||
{
|
||||
id: "starter",
|
||||
name: "Starter",
|
||||
id: "basic",
|
||||
name: "Basic",
|
||||
price: "Free",
|
||||
tierType: null
|
||||
},
|
||||
@@ -109,10 +109,10 @@ const planOptions: PlanOption[] = [
|
||||
|
||||
// Tier limits mapping derived from limit sets
|
||||
const tierLimits: Record<
|
||||
Tier | "starter",
|
||||
Tier | "basic",
|
||||
{ users: number; sites: number; domains: number; remoteNodes: number }
|
||||
> = {
|
||||
starter: {
|
||||
basic: {
|
||||
users: freeLimitSet[FeatureId.USERS]?.value ?? 0,
|
||||
sites: freeLimitSet[FeatureId.SITES]?.value ?? 0,
|
||||
domains: freeLimitSet[FeatureId.DOMAINS]?.value ?? 0,
|
||||
@@ -183,7 +183,7 @@ export default function BillingPage() {
|
||||
// Confirmation dialog state
|
||||
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
||||
const [pendingTier, setPendingTier] = useState<{
|
||||
tier: Tier | "starter";
|
||||
tier: Tier | "basic";
|
||||
action: "upgrade" | "downgrade";
|
||||
planName: string;
|
||||
price: string;
|
||||
@@ -402,8 +402,8 @@ export default function BillingPage() {
|
||||
pendingTier.action === "upgrade" ||
|
||||
pendingTier.action === "downgrade"
|
||||
) {
|
||||
// If downgrading to starter (free tier), go to Stripe portal
|
||||
if (pendingTier.tier === "starter") {
|
||||
// If downgrading to basic (free tier), go to Stripe portal
|
||||
if (pendingTier.tier === "basic") {
|
||||
handleModifySubscription();
|
||||
} else if (hasSubscription) {
|
||||
handleChangeTier(pendingTier.tier);
|
||||
@@ -417,7 +417,7 @@ export default function BillingPage() {
|
||||
};
|
||||
|
||||
const showTierConfirmation = (
|
||||
tier: Tier | "starter",
|
||||
tier: Tier | "basic",
|
||||
action: "upgrade" | "downgrade",
|
||||
planName: string,
|
||||
price: string
|
||||
@@ -432,9 +432,9 @@ export default function BillingPage() {
|
||||
|
||||
// Get current plan ID from tier
|
||||
const getCurrentPlanId = (): PlanId => {
|
||||
if (!hasSubscription || !currentTier) return "starter";
|
||||
if (!hasSubscription || !currentTier) return "basic";
|
||||
const plan = planOptions.find((p) => p.tierType === currentTier);
|
||||
return plan?.id || "starter";
|
||||
return plan?.id || "basic";
|
||||
};
|
||||
|
||||
const currentPlanId = getCurrentPlanId();
|
||||
@@ -451,8 +451,8 @@ export default function BillingPage() {
|
||||
}
|
||||
|
||||
if (plan.id === currentPlanId) {
|
||||
// If it's the starter plan (starter with no subscription), show as current but disabled
|
||||
if (plan.id === "starter" && !hasSubscription) {
|
||||
// If it's the basic plan (basic with no subscription), show as current but disabled
|
||||
if (plan.id === "basic" && !hasSubscription) {
|
||||
return {
|
||||
label: "Current Plan",
|
||||
action: () => {},
|
||||
@@ -484,10 +484,10 @@ export default function BillingPage() {
|
||||
plan.name,
|
||||
plan.price + (" " + plan.priceDetail || "")
|
||||
);
|
||||
} else if (plan.id === "starter") {
|
||||
// Show confirmation for downgrading to starter (free tier)
|
||||
} else if (plan.id === "basic") {
|
||||
// Show confirmation for downgrading to basic (free tier)
|
||||
showTierConfirmation(
|
||||
"starter",
|
||||
"basic",
|
||||
"downgrade",
|
||||
plan.name,
|
||||
plan.price
|
||||
@@ -566,7 +566,7 @@ export default function BillingPage() {
|
||||
};
|
||||
|
||||
// Check if downgrading to a tier would violate current usage limits
|
||||
const checkLimitViolations = (targetTier: Tier | "starter"): Array<{
|
||||
const checkLimitViolations = (targetTier: Tier | "basic"): Array<{
|
||||
feature: string;
|
||||
currentUsage: number;
|
||||
newLimit: number;
|
||||
|
||||
74
src/app/auth/delete-account/DeleteAccountClient.tsx
Normal file
74
src/app/auth/delete-account/DeleteAccountClient.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import DeleteAccountConfirmDialog from "@app/components/DeleteAccountConfirmDialog";
|
||||
import UserProfileCard from "@app/components/UserProfileCard";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { formatAxiosError } from "@app/lib/api";
|
||||
|
||||
type DeleteAccountClientProps = {
|
||||
displayName: string;
|
||||
};
|
||||
|
||||
export default function DeleteAccountClient({
|
||||
displayName
|
||||
}: DeleteAccountClientProps) {
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
const { env } = useEnvContext();
|
||||
const api = createApiClient({ env });
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
|
||||
function handleUseDifferentAccount() {
|
||||
api.post("/auth/logout")
|
||||
.catch((e) => {
|
||||
console.error(t("logoutError"), e);
|
||||
toast({
|
||||
title: t("logoutError"),
|
||||
description: formatAxiosError(e, t("logoutError"))
|
||||
});
|
||||
})
|
||||
.then(() => {
|
||||
router.push(
|
||||
"/auth/login?internal_redirect=/auth/delete-account"
|
||||
);
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<UserProfileCard
|
||||
identifier={displayName}
|
||||
description={t("signingAs")}
|
||||
onUseDifferentAccount={handleUseDifferentAccount}
|
||||
useDifferentAccountText={t("deviceLoginUseDifferentAccount")}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("deleteAccountDescription")}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
{t("back")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => setIsDialogOpen(true)}
|
||||
>
|
||||
{t("deleteAccountButton")}
|
||||
</Button>
|
||||
</div>
|
||||
<DeleteAccountConfirmDialog
|
||||
open={isDialogOpen}
|
||||
setOpen={setIsDialogOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
28
src/app/auth/delete-account/page.tsx
Normal file
28
src/app/auth/delete-account/page.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { verifySession } from "@app/lib/auth/verifySession";
|
||||
import { redirect } from "next/navigation";
|
||||
import { build } from "@server/build";
|
||||
import { cache } from "react";
|
||||
import DeleteAccountClient from "./DeleteAccountClient";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function DeleteAccountPage() {
|
||||
const getUser = cache(verifySession);
|
||||
const user = await getUser({ skipCheckVerifyEmail: true });
|
||||
|
||||
if (!user) {
|
||||
redirect("/auth/login");
|
||||
}
|
||||
|
||||
const t = await getTranslations();
|
||||
const displayName = getUserDisplayName({ user });
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-xl font-semibold">{t("deleteAccount")}</h1>
|
||||
<DeleteAccountClient displayName={displayName} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import type {
|
||||
import { CheckOrgUserAccessResponse } from "@server/routers/org";
|
||||
import OrgPolicyRequired from "@app/components/OrgPolicyRequired";
|
||||
import { isOrgSubscribed } from "@app/lib/api/isOrgSubscribed";
|
||||
import { normalizePostAuthPath } from "@server/lib/normalizePostAuthPath";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -108,6 +109,11 @@ export default async function ResourceAuthPage(props: {
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
const normalizedPostAuthPath = normalizePostAuthPath(authInfo.postAuthPath);
|
||||
if (normalizedPostAuthPath) {
|
||||
redirectUrl = new URL(authInfo.url).origin + normalizedPostAuthPath;
|
||||
}
|
||||
|
||||
const hasAuth =
|
||||
authInfo.password ||
|
||||
authInfo.pincode ||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { consumeInternalRedirectPath } from "@app/lib/internalRedirect";
|
||||
import { getInternalRedirectTarget } from "@app/lib/internalRedirect";
|
||||
|
||||
type ApplyInternalRedirectProps = {
|
||||
orgId: string;
|
||||
@@ -14,9 +14,9 @@ export default function ApplyInternalRedirect({
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
const path = consumeInternalRedirectPath();
|
||||
if (path) {
|
||||
router.replace(`/${orgId}${path}`);
|
||||
const target = getInternalRedirectTarget(orgId);
|
||||
if (target) {
|
||||
router.replace(target);
|
||||
}
|
||||
}, [orgId, router]);
|
||||
|
||||
|
||||
@@ -303,7 +303,7 @@ export default function CreateInternalResourceDialog({
|
||||
const [udpCustomPorts, setUdpCustomPorts] = useState<string>("");
|
||||
|
||||
const availableSites = sites.filter(
|
||||
(site) => site.type === "newt" && site.subnet
|
||||
(site) => site.type === "newt"
|
||||
);
|
||||
|
||||
const form = useForm<FormData>({
|
||||
|
||||
414
src/components/DeleteAccountConfirmDialog.tsx
Normal file
414
src/components/DeleteAccountConfirmDialog.tsx
Normal file
@@ -0,0 +1,414 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { formatAxiosError } from "@app/lib/api";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
Credenza,
|
||||
CredenzaBody,
|
||||
CredenzaClose,
|
||||
CredenzaContent,
|
||||
CredenzaFooter,
|
||||
CredenzaHeader,
|
||||
CredenzaTitle
|
||||
} from "@app/components/Credenza";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
InputOTP,
|
||||
InputOTPGroup,
|
||||
InputOTPSlot
|
||||
} from "@app/components/ui/input-otp";
|
||||
import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp";
|
||||
import type {
|
||||
DeleteMyAccountPreviewResponse,
|
||||
DeleteMyAccountCodeRequestedResponse,
|
||||
DeleteMyAccountSuccessResponse
|
||||
} from "@server/routers/auth/deleteMyAccount";
|
||||
import { AxiosResponse } from "axios";
|
||||
|
||||
type DeleteAccountConfirmDialogProps = {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export default function DeleteAccountConfirmDialog({
|
||||
open,
|
||||
setOpen
|
||||
}: DeleteAccountConfirmDialogProps) {
|
||||
const { env } = useEnvContext();
|
||||
const api = createApiClient({ env });
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
|
||||
const passwordSchema = useMemo(
|
||||
() =>
|
||||
z.object({
|
||||
password: z.string().min(1, { message: t("passwordRequired") })
|
||||
}),
|
||||
[t]
|
||||
);
|
||||
|
||||
const codeSchema = useMemo(
|
||||
() =>
|
||||
z.object({
|
||||
code: z.string().length(6, { message: t("pincodeInvalid") })
|
||||
}),
|
||||
[t]
|
||||
);
|
||||
|
||||
const [step, setStep] = useState<0 | 1 | 2>(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadingPreview, setLoadingPreview] = useState(false);
|
||||
const [preview, setPreview] =
|
||||
useState<DeleteMyAccountPreviewResponse | null>(null);
|
||||
const [passwordValue, setPasswordValue] = useState("");
|
||||
|
||||
const passwordForm = useForm<z.infer<typeof passwordSchema>>({
|
||||
resolver: zodResolver(passwordSchema),
|
||||
defaultValues: { password: "" }
|
||||
});
|
||||
|
||||
const codeForm = useForm<z.infer<typeof codeSchema>>({
|
||||
resolver: zodResolver(codeSchema),
|
||||
defaultValues: { code: "" }
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (open && step === 0 && !preview) {
|
||||
setLoadingPreview(true);
|
||||
api.post<AxiosResponse<DeleteMyAccountPreviewResponse>>(
|
||||
"/auth/delete-my-account",
|
||||
{}
|
||||
)
|
||||
.then((res) => {
|
||||
if (res.data?.data?.preview) {
|
||||
setPreview(res.data.data);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("deleteAccountError"),
|
||||
description: formatAxiosError(
|
||||
err,
|
||||
t("deleteAccountError")
|
||||
)
|
||||
});
|
||||
setOpen(false);
|
||||
})
|
||||
.finally(() => setLoadingPreview(false));
|
||||
}
|
||||
}, [open, step, preview, api, setOpen, t]);
|
||||
|
||||
function reset() {
|
||||
setStep(0);
|
||||
setPreview(null);
|
||||
setPasswordValue("");
|
||||
passwordForm.reset();
|
||||
codeForm.reset();
|
||||
}
|
||||
|
||||
async function handleContinueToPassword() {
|
||||
setStep(1);
|
||||
}
|
||||
|
||||
async function handlePasswordSubmit(
|
||||
values: z.infer<typeof passwordSchema>
|
||||
) {
|
||||
setLoading(true);
|
||||
setPasswordValue(values.password);
|
||||
try {
|
||||
const res = await api.post<
|
||||
| AxiosResponse<DeleteMyAccountCodeRequestedResponse>
|
||||
| AxiosResponse<DeleteMyAccountSuccessResponse>
|
||||
>("/auth/delete-my-account", { password: values.password });
|
||||
|
||||
const data = res.data?.data;
|
||||
|
||||
if (data && "codeRequested" in data && data.codeRequested) {
|
||||
setStep(2);
|
||||
} else if (data && "success" in data && data.success) {
|
||||
toast({
|
||||
title: t("deleteAccountSuccess"),
|
||||
description: t("deleteAccountSuccessMessage")
|
||||
});
|
||||
setOpen(false);
|
||||
reset();
|
||||
router.push("/auth/login");
|
||||
router.refresh();
|
||||
}
|
||||
} catch (err) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("deleteAccountError"),
|
||||
description: formatAxiosError(err, t("deleteAccountError"))
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCodeSubmit(values: z.infer<typeof codeSchema>) {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.post<
|
||||
AxiosResponse<DeleteMyAccountSuccessResponse>
|
||||
>("/auth/delete-my-account", {
|
||||
password: passwordValue,
|
||||
code: values.code
|
||||
});
|
||||
|
||||
if (res.data?.data?.success) {
|
||||
toast({
|
||||
title: t("deleteAccountSuccess"),
|
||||
description: t("deleteAccountSuccessMessage")
|
||||
});
|
||||
setOpen(false);
|
||||
reset();
|
||||
router.push("/auth/login");
|
||||
router.refresh();
|
||||
}
|
||||
} catch (err) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("deleteAccountError"),
|
||||
description: formatAxiosError(err, t("deleteAccountError"))
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Credenza
|
||||
open={open}
|
||||
onOpenChange={(val) => {
|
||||
setOpen(val);
|
||||
if (!val) reset();
|
||||
}}
|
||||
>
|
||||
<CredenzaContent>
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>
|
||||
{t("deleteAccountConfirmTitle")}
|
||||
</CredenzaTitle>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
<div className="space-y-4">
|
||||
{step === 0 && (
|
||||
<>
|
||||
{loadingPreview ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("loading")}...
|
||||
</p>
|
||||
) : preview ? (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("deleteAccountConfirmMessage")}
|
||||
</p>
|
||||
<div className="rounded-md bg-muted p-3 space-y-2">
|
||||
<p className="text-sm font-medium">
|
||||
{t(
|
||||
"deleteAccountPreviewAccount"
|
||||
)}
|
||||
</p>
|
||||
{preview.orgs.length > 0 && (
|
||||
<>
|
||||
<p className="text-sm font-medium mt-2">
|
||||
{t(
|
||||
"deleteAccountPreviewOrgs"
|
||||
)}
|
||||
</p>
|
||||
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1">
|
||||
{preview.orgs.map(
|
||||
(org) => (
|
||||
<li
|
||||
key={
|
||||
org.orgId
|
||||
}
|
||||
>
|
||||
{org.name ||
|
||||
org.orgId}
|
||||
</li>
|
||||
)
|
||||
)}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm font-bold text-destructive">
|
||||
{t("cannotbeUndone")}
|
||||
</p>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === 1 && (
|
||||
<Form {...passwordForm}>
|
||||
<form
|
||||
id="delete-account-password-form"
|
||||
onSubmit={passwordForm.handleSubmit(
|
||||
handlePasswordSubmit
|
||||
)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<FormField
|
||||
control={passwordForm.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("password")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<div className="space-y-4">
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("otpAuthDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<Form {...codeForm}>
|
||||
<form
|
||||
id="delete-account-code-form"
|
||||
onSubmit={codeForm.handleSubmit(
|
||||
handleCodeSubmit
|
||||
)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<FormField
|
||||
control={codeForm.control}
|
||||
name="code"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<div className="flex justify-center">
|
||||
<InputOTP
|
||||
maxLength={6}
|
||||
{...field}
|
||||
pattern={
|
||||
REGEXP_ONLY_DIGITS_AND_CHARS
|
||||
}
|
||||
onChange={(
|
||||
value: string
|
||||
) => {
|
||||
field.onChange(
|
||||
value
|
||||
);
|
||||
}}
|
||||
>
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot
|
||||
index={
|
||||
0
|
||||
}
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={
|
||||
1
|
||||
}
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={
|
||||
2
|
||||
}
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={
|
||||
3
|
||||
}
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={
|
||||
4
|
||||
}
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={
|
||||
5
|
||||
}
|
||||
/>
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">{t("close")}</Button>
|
||||
</CredenzaClose>
|
||||
{step === 0 && preview && !loadingPreview && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleContinueToPassword}
|
||||
>
|
||||
{t("continue")}
|
||||
</Button>
|
||||
)}
|
||||
{step === 1 && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
type="submit"
|
||||
form="delete-account-password-form"
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
>
|
||||
{t("deleteAccountButton")}
|
||||
</Button>
|
||||
)}
|
||||
{step === 2 && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
type="submit"
|
||||
form="delete-account-code-form"
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
>
|
||||
{t("deleteAccountButton")}
|
||||
</Button>
|
||||
)}
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
</Credenza>
|
||||
);
|
||||
}
|
||||
@@ -397,7 +397,7 @@ export default function EditInternalResourceDialog({
|
||||
);
|
||||
|
||||
const availableSites = sites.filter(
|
||||
(site) => site.type === "newt" && site.subnet
|
||||
(site) => site.type === "newt"
|
||||
);
|
||||
|
||||
const form = useForm<FormData>({
|
||||
|
||||
@@ -37,7 +37,7 @@ export const MachineClientsBanner = ({ orgId }: MachineClientsBannerProps) => {
|
||||
</Button>
|
||||
</Link>
|
||||
<Link
|
||||
href="https://docs.pangolin.net/manage/clients/install-client#docker"
|
||||
href="https://docs.pangolin.net/manage/clients/install-client#docker-pangolin-cli"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
|
||||
@@ -15,9 +15,11 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { formatAxiosError } from "@app/lib/api";
|
||||
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
|
||||
import { Laptop, LogOut, Moon, Sun, Smartphone } from "lucide-react";
|
||||
import { Laptop, LogOut, Moon, Sun, Smartphone, Trash2 } from "lucide-react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { build } from "@server/build";
|
||||
import { useState } from "react";
|
||||
import { useUserContext } from "@app/hooks/useUserContext";
|
||||
import Disable2FaForm from "./Disable2FaForm";
|
||||
@@ -187,6 +189,20 @@ export default function ProfileIcon() {
|
||||
<DropdownMenuSeparator />
|
||||
<LocaleSwitcher />
|
||||
<DropdownMenuSeparator />
|
||||
{user?.type === UserType.Internal && !user?.serverAdmin && (
|
||||
<>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
href="/auth/delete-account"
|
||||
className="flex cursor-pointer items-center"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
<span>{t("deleteAccount")}</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuItem onClick={() => logout()}>
|
||||
{/* <LogOut className="mr-2 h-4 w-4" /> */}
|
||||
<span>{t("logout")}</span>
|
||||
|
||||
@@ -13,7 +13,8 @@ export default function RedirectToOrg({ targetOrgId }: RedirectToOrgProps) {
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const target = getInternalRedirectTarget(targetOrgId);
|
||||
const target =
|
||||
getInternalRedirectTarget(targetOrgId) ?? `/${targetOrgId}`;
|
||||
router.replace(target);
|
||||
} catch {
|
||||
router.replace(`/${targetOrgId}`);
|
||||
|
||||
@@ -18,11 +18,11 @@ export type CommandItem = string | { title: string; command: string };
|
||||
|
||||
const PLATFORMS = [
|
||||
"unix",
|
||||
"windows",
|
||||
"docker",
|
||||
"kubernetes",
|
||||
"podman",
|
||||
"nixos"
|
||||
"nixos",
|
||||
"windows"
|
||||
] as const;
|
||||
|
||||
type Platform = (typeof PLATFORMS)[number];
|
||||
|
||||
@@ -14,7 +14,7 @@ import { Button } from "./ui/button";
|
||||
|
||||
export type CommandItem = string | { title: string; command: string };
|
||||
|
||||
const PLATFORMS = ["unix", "windows", "docker"] as const;
|
||||
const PLATFORMS = ["unix", "docker", "windows"] as const;
|
||||
|
||||
type Platform = (typeof PLATFORMS)[number];
|
||||
|
||||
@@ -43,7 +43,7 @@ export function OlmInstallCommands({
|
||||
All: [
|
||||
{
|
||||
title: t("install"),
|
||||
command: `curl -fsSL https://static.pangolin.net/get-cli.sh | bash`
|
||||
command: `curl -fsSL https://static.pangolin.net/get-cli.sh | sudo bash`
|
||||
},
|
||||
{
|
||||
title: t("run"),
|
||||
@@ -51,24 +51,12 @@ export function OlmInstallCommands({
|
||||
}
|
||||
]
|
||||
},
|
||||
windows: {
|
||||
x64: [
|
||||
{
|
||||
title: t("install"),
|
||||
command: `curl -o olm.exe -L "https://github.com/fosrl/olm/releases/download/${version}/olm_windows_installer.exe"`
|
||||
},
|
||||
{
|
||||
title: t("run"),
|
||||
command: `olm.exe --id ${id} --secret ${secret} --endpoint ${endpoint}`
|
||||
}
|
||||
]
|
||||
},
|
||||
docker: {
|
||||
"Docker Compose": [
|
||||
`services:
|
||||
olm:
|
||||
image: fosrl/olm
|
||||
container_name: olm
|
||||
pangolin-cli:
|
||||
image: fosrl/pangolin-cli
|
||||
container_name: pangolin-cli
|
||||
restart: unless-stopped
|
||||
network_mode: host
|
||||
cap_add:
|
||||
@@ -77,11 +65,24 @@ export function OlmInstallCommands({
|
||||
- /dev/net/tun:/dev/net/tun
|
||||
environment:
|
||||
- PANGOLIN_ENDPOINT=${endpoint}
|
||||
- OLM_ID=${id}
|
||||
- OLM_SECRET=${secret}`
|
||||
- CLIENT_ID=${id}
|
||||
- CLIENT_SECRET=${secret}`
|
||||
],
|
||||
"Docker Run": [
|
||||
`docker run -dit --network host --cap-add NET_ADMIN --device /dev/net/tun:/dev/net/tun fosrl/olm --id ${id} --secret ${secret} --endpoint ${endpoint}`
|
||||
`docker run -dit --network host --cap-add NET_ADMIN --device /dev/net/tun:/dev/net/tun fosrl/pangolin-cli up client --id ${id} --secret ${secret} --endpoint ${endpoint} --attach`
|
||||
]
|
||||
},
|
||||
windows: {
|
||||
x64: [
|
||||
{
|
||||
title: t("install"),
|
||||
command: `# Download and run the installer to install Olm first\n
|
||||
curl -o olm.exe -L "https://github.com/fosrl/olm/releases/download/${version}/olm_windows_installer.exe"`
|
||||
},
|
||||
{
|
||||
title: t("run"),
|
||||
command: `olm.exe --id ${id} --secret ${secret} --endpoint ${endpoint}`
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
@@ -41,11 +41,12 @@ export function consumeInternalRedirectPath(): string | null {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the full redirect target for an org: either `/${orgId}` or
|
||||
* `/${orgId}${path}` if a valid internal_redirect was stored. Consumes the
|
||||
* stored value.
|
||||
* Returns the full redirect target if a valid internal_redirect was stored
|
||||
* (consumes the stored value). Returns null if none was stored or expired.
|
||||
* Paths starting with /auth/ are returned as-is; others are prefixed with orgId.
|
||||
*/
|
||||
export function getInternalRedirectTarget(orgId: string): string {
|
||||
export function getInternalRedirectTarget(orgId: string): string | null {
|
||||
const path = consumeInternalRedirectPath();
|
||||
return path ? `/${orgId}${path}` : `/${orgId}`;
|
||||
if (!path) return null;
|
||||
return path.startsWith("/auth/") ? path : `/${orgId}${path}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user