From f9f47f3869e9ae1b5effa3e07877a18427cfafc7 Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 2 Sep 2025 16:11:08 -0700 Subject: [PATCH 001/166] Convert to exitNodeComm function --- server/lib/exitNodeComms.ts | 2 +- server/lib/traefikConfig.ts | 27 ++++++--------------------- 2 files changed, 7 insertions(+), 22 deletions(-) diff --git a/server/lib/exitNodeComms.ts b/server/lib/exitNodeComms.ts index f79b718f..bcfbec3e 100644 --- a/server/lib/exitNodeComms.ts +++ b/server/lib/exitNodeComms.ts @@ -3,7 +3,7 @@ import logger from "@server/logger"; import { ExitNode } from "@server/db"; interface ExitNodeRequest { - remoteType: string; + remoteType?: string; localPath: string; method?: "POST" | "DELETE" | "GET" | "PUT"; data?: any; diff --git a/server/lib/traefikConfig.ts b/server/lib/traefikConfig.ts index e16b93d2..8b133419 100644 --- a/server/lib/traefikConfig.ts +++ b/server/lib/traefikConfig.ts @@ -15,6 +15,7 @@ import { getValidCertificatesForDomains, getValidCertificatesForDomainsHybrid } from "./remoteCertificates"; +import { sendToExitNode } from "./exitNodeComms"; export class TraefikConfigManager { private intervalId: NodeJS.Timeout | null = null; @@ -403,27 +404,11 @@ export class TraefikConfigManager { [exitNode] = await db.select().from(exitNodes).limit(1); } if (exitNode) { - try { - await axios.post( - `${exitNode.reachableAt}/update-local-snis`, - { fullDomains: Array.from(domains) }, - { headers: { "Content-Type": "application/json" } } - ); - } catch (error) { - // pull data out of the axios error to log - if (axios.isAxiosError(error)) { - logger.error("Error updating local SNI:", { - message: error.message, - code: error.code, - status: error.response?.status, - statusText: error.response?.statusText, - url: error.config?.url, - method: error.config?.method - }); - } else { - logger.error("Error updating local SNI:", error); - } - } + await sendToExitNode(exitNode, { + localPath: "/update-local-snis", + method: "POST", + data: { fullDomains: Array.from(domains) } + }); } else { logger.error( "No exit node found. Has gerbil registered yet?" From a21de9f1cbdc25aed45b7935de2697b289c6b83b Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 3 Sep 2025 17:34:16 -0700 Subject: [PATCH 002/166] Add niceId to resource --- server/db/pg/schema.ts | 1 + server/db/sqlite/schema.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/server/db/pg/schema.ts b/server/db/pg/schema.ts index 8e725ab1..624b4a61 100644 --- a/server/db/pg/schema.ts +++ b/server/db/pg/schema.ts @@ -71,6 +71,7 @@ export const resources = pgTable("resources", { onDelete: "cascade" }) .notNull(), + niceId: text("niceId"), // TODO: SHOULD THIS BE NULLABLE? name: varchar("name").notNull(), subdomain: varchar("subdomain"), fullDomain: varchar("fullDomain"), diff --git a/server/db/sqlite/schema.ts b/server/db/sqlite/schema.ts index 579ff7b4..971c1841 100644 --- a/server/db/sqlite/schema.ts +++ b/server/db/sqlite/schema.ts @@ -77,6 +77,7 @@ export const resources = sqliteTable("resources", { onDelete: "cascade" }) .notNull(), + niceId: text("niceId"), // TODO: SHOULD THIS BE NULLABLE? name: text("name").notNull(), subdomain: text("subdomain"), fullDomain: text("fullDomain"), From 459853ca997d01dc8658cbf672d83ab4ddd50531 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Thu, 4 Sep 2025 11:18:42 -0700 Subject: [PATCH 003/166] move all components to components dir --- .../routers/resource/getResourceAuthInfo.ts | 4 +- src/app/[orgId]/page.tsx | 4 +- .../settings/access/invitations/page.tsx | 4 +- .../[orgId]/settings/access/roles/page.tsx | 2 +- .../[orgId]/settings/access/users/page.tsx | 4 +- src/app/[orgId]/settings/api-keys/page.tsx | 4 +- .../settings/clients/[clientId]/layout.tsx | 2 +- src/app/[orgId]/settings/clients/page.tsx | 4 +- src/app/[orgId]/settings/domains/page.tsx | 2 +- .../[resourceId]/authentication/page.tsx | 4 +- .../resources/[resourceId]/general/page.tsx | 4 +- .../resources/[resourceId]/layout.tsx | 2 +- .../settings/resources/create/page.tsx | 2 +- src/app/[orgId]/settings/resources/page.tsx | 2 +- .../share-links/CreateShareLinkForm.tsx | 4 +- .../settings/share-links/ShareLinksTable.tsx | 4 +- src/app/[orgId]/settings/share-links/page.tsx | 4 +- .../settings/sites/[niceId]/layout.tsx | 2 +- src/app/[orgId]/settings/sites/page.tsx | 4 +- src/app/admin/api-keys/page.tsx | 2 +- src/app/admin/idp/[idpId]/policies/page.tsx | 2 +- src/app/admin/idp/page.tsx | 4 +- src/app/admin/users/AdminUsersTable.tsx | 2 +- src/app/admin/users/page.tsx | 2 +- .../auth/idp/[idpId]/oidc/callback/page.tsx | 2 +- src/app/auth/login/page.tsx | 2 +- .../auth/reset-password/ResetPasswordForm.tsx | 32 +- src/app/auth/reset-password/page.tsx | 2 +- src/app/auth/resource/[resourceId]/page.tsx | 10 +- src/app/auth/signup/page.tsx | 4 +- src/app/auth/verify-email/page.tsx | 2 +- src/app/invite/page.tsx | 6 +- src/app/s/[accessToken]/page.tsx | 2 +- .../AccessPageHeaderAndNav.tsx | 0 .../AccessToken.tsx | 0 .../AccessTokenUsage.tsx | 0 .../idp => components}/AdminIdpDataTable.tsx | 0 .../idp => components}/AdminIdpTable.tsx | 2 +- .../AdminUsersDataTable.tsx | 0 src/components/AdminUsersTable.tsx | 269 +++++++++ .../ApiKeysDataTable.tsx | 0 .../api-keys => components}/ApiKeysTable.tsx | 2 +- .../AutoLoginHandler.tsx | 0 .../ClientInfoCard.tsx | 0 .../ClientsDataTable.tsx | 0 .../clients => components}/ClientsTable.tsx | 2 +- .../CreateDomainForm.tsx | 0 .../roles => components}/CreateRoleForm.tsx | 0 src/components/CreateShareLinkForm.tsx | 549 ++++++++++++++++++ .../CustomDomainInput.tsx | 0 .../DashboardLoginForm.tsx | 0 .../roles => components}/DeleteRoleForm.tsx | 2 +- .../DomainsDataTable.tsx | 0 .../domains => components}/DomainsTable.tsx | 4 +- src/components/IdpCreateWizard.tsx | 429 ++++++++++++++ .../InvitationsDataTable.tsx | 0 .../InvitationsTable.tsx | 4 +- .../InviteStatusCard.tsx | 0 .../MemberResourcesPortal.tsx | 0 .../OrgApiKeysDataTable.tsx | 0 .../OrgApiKeysTable.tsx | 2 +- .../OrganizationLandingCard.tsx | 0 .../PolicyDataTable.tsx | 0 .../policies => components}/PolicyTable.tsx | 0 .../RegenerateInvitationForm.tsx | 0 src/components/ResetPasswordForm.tsx | 533 +++++++++++++++++ .../ResourceAccessDenied.tsx | 0 .../ResourceAuthPortal.tsx | 2 +- .../ResourceInfoBox.tsx | 0 .../ResourceNotFound.tsx | 0 .../ResourcesTable.tsx | 0 .../roles => components}/RolesDataTable.tsx | 0 .../roles => components}/RolesTable.tsx | 6 +- .../SetResourcePasswordForm.tsx | 0 .../SetResourcePincodeForm.tsx | 0 .../ShareLinksDataTable.tsx | 0 .../ShareLinksSplash.tsx | 0 src/components/ShareLinksTable.tsx | 298 ++++++++++ .../auth/signup => components}/SignupForm.tsx | 0 .../[niceId] => components}/SiteInfoCard.tsx | 0 .../sites => components}/SitesDataTable.tsx | 0 .../sites => components}/SitesSplashCard.tsx | 0 .../sites => components}/SitesTable.tsx | 2 +- .../users => components}/UsersDataTable.tsx | 0 .../users => components}/UsersTable.tsx | 2 +- .../ValidateOidcToken.tsx | 0 .../VerifyEmailForm.tsx | 2 +- 87 files changed, 2163 insertions(+), 83 deletions(-) rename src/{app/[orgId]/settings/access => components}/AccessPageHeaderAndNav.tsx (100%) rename src/{app/auth/resource/[resourceId] => components}/AccessToken.tsx (100%) rename src/{app/[orgId]/settings/share-links => components}/AccessTokenUsage.tsx (100%) rename src/{app/admin/idp => components}/AdminIdpDataTable.tsx (100%) rename src/{app/admin/idp => components}/AdminIdpTable.tsx (99%) rename src/{app/admin/users => components}/AdminUsersDataTable.tsx (100%) create mode 100644 src/components/AdminUsersTable.tsx rename src/{app/admin/api-keys => components}/ApiKeysDataTable.tsx (100%) rename src/{app/admin/api-keys => components}/ApiKeysTable.tsx (98%) rename src/{app/auth/resource/[resourceId] => components}/AutoLoginHandler.tsx (100%) rename src/{app/[orgId]/settings/clients/[clientId] => components}/ClientInfoCard.tsx (100%) rename src/{app/[orgId]/settings/clients => components}/ClientsDataTable.tsx (100%) rename src/{app/[orgId]/settings/clients => components}/ClientsTable.tsx (99%) rename src/{app/[orgId]/settings/domains => components}/CreateDomainForm.tsx (100%) rename src/{app/[orgId]/settings/access/roles => components}/CreateRoleForm.tsx (100%) create mode 100644 src/components/CreateShareLinkForm.tsx rename src/{app/[orgId]/settings/resources/[resourceId] => components}/CustomDomainInput.tsx (100%) rename src/{app/auth/login => components}/DashboardLoginForm.tsx (100%) rename src/{app/[orgId]/settings/access/roles => components}/DeleteRoleForm.tsx (99%) rename src/{app/[orgId]/settings/domains => components}/DomainsDataTable.tsx (100%) rename src/{app/[orgId]/settings/domains => components}/DomainsTable.tsx (98%) create mode 100644 src/components/IdpCreateWizard.tsx rename src/{app/[orgId]/settings/access/invitations => components}/InvitationsDataTable.tsx (100%) rename src/{app/[orgId]/settings/access/invitations => components}/InvitationsTable.tsx (97%) rename src/{app/invite => components}/InviteStatusCard.tsx (100%) rename src/{app/[orgId] => components}/MemberResourcesPortal.tsx (100%) rename src/{app/[orgId]/settings/api-keys => components}/OrgApiKeysDataTable.tsx (100%) rename src/{app/[orgId]/settings/api-keys => components}/OrgApiKeysTable.tsx (98%) rename src/{app/[orgId] => components}/OrganizationLandingCard.tsx (100%) rename src/{app/admin/idp/[idpId]/policies => components}/PolicyDataTable.tsx (100%) rename src/{app/admin/idp/[idpId]/policies => components}/PolicyTable.tsx (100%) rename src/{app/[orgId]/settings/access/invitations => components}/RegenerateInvitationForm.tsx (100%) create mode 100644 src/components/ResetPasswordForm.tsx rename src/{app/auth/resource/[resourceId] => components}/ResourceAccessDenied.tsx (100%) rename src/{app/auth/resource/[resourceId] => components}/ResourceAuthPortal.tsx (99%) rename src/{app/[orgId]/settings/resources/[resourceId] => components}/ResourceInfoBox.tsx (100%) rename src/{app/auth/resource/[resourceId] => components}/ResourceNotFound.tsx (100%) rename src/{app/[orgId]/settings/resources => components}/ResourcesTable.tsx (100%) rename src/{app/[orgId]/settings/access/roles => components}/RolesDataTable.tsx (100%) rename src/{app/[orgId]/settings/access/roles => components}/RolesTable.tsx (95%) rename src/{app/[orgId]/settings/resources/[resourceId]/authentication => components}/SetResourcePasswordForm.tsx (100%) rename src/{app/[orgId]/settings/resources/[resourceId]/authentication => components}/SetResourcePincodeForm.tsx (100%) rename src/{app/[orgId]/settings/share-links => components}/ShareLinksDataTable.tsx (100%) rename src/{app/[orgId]/settings/share-links => components}/ShareLinksSplash.tsx (100%) create mode 100644 src/components/ShareLinksTable.tsx rename src/{app/auth/signup => components}/SignupForm.tsx (100%) rename src/{app/[orgId]/settings/sites/[niceId] => components}/SiteInfoCard.tsx (100%) rename src/{app/[orgId]/settings/sites => components}/SitesDataTable.tsx (100%) rename src/{app/[orgId]/settings/sites => components}/SitesSplashCard.tsx (100%) rename src/{app/[orgId]/settings/sites => components}/SitesTable.tsx (99%) rename src/{app/[orgId]/settings/access/users => components}/UsersDataTable.tsx (100%) rename src/{app/[orgId]/settings/access/users => components}/UsersTable.tsx (99%) rename src/{app/auth/idp/[idpId]/oidc/callback => components}/ValidateOidcToken.tsx (100%) rename src/{app/auth/verify-email => components}/VerifyEmailForm.tsx (99%) diff --git a/server/routers/resource/getResourceAuthInfo.ts b/server/routers/resource/getResourceAuthInfo.ts index 191221f1..c775564b 100644 --- a/server/routers/resource/getResourceAuthInfo.ts +++ b/server/routers/resource/getResourceAuthInfo.ts @@ -32,6 +32,7 @@ export type GetResourceAuthInfoResponse = { url: string; whitelist: boolean; skipToIdpId: number | null; + orgId: string; }; export async function getResourceAuthInfo( @@ -88,7 +89,8 @@ export async function getResourceAuthInfo( blockAccess: resource.blockAccess, url, whitelist: resource.emailWhitelistEnabled, - skipToIdpId: resource.skipToIdpId + skipToIdpId: resource.skipToIdpId, + orgId: resource.orgId }, success: true, error: false, diff --git a/src/app/[orgId]/page.tsx b/src/app/[orgId]/page.tsx index 4740198b..4c3ac07b 100644 --- a/src/app/[orgId]/page.tsx +++ b/src/app/[orgId]/page.tsx @@ -1,8 +1,8 @@ import { verifySession } from "@app/lib/auth/verifySession"; import UserProvider from "@app/providers/UserProvider"; import { cache } from "react"; -import OrganizationLandingCard from "./OrganizationLandingCard"; -import MemberResourcesPortal from "./MemberResourcesPortal"; +import OrganizationLandingCard from "../../components/OrganizationLandingCard"; +import MemberResourcesPortal from "../../components/MemberResourcesPortal"; import { GetOrgOverviewResponse } from "@server/routers/org/getOrgOverview"; import { internal } from "@app/lib/api"; import { AxiosResponse } from "axios"; diff --git a/src/app/[orgId]/settings/access/invitations/page.tsx b/src/app/[orgId]/settings/access/invitations/page.tsx index 665b9a43..d7fee322 100644 --- a/src/app/[orgId]/settings/access/invitations/page.tsx +++ b/src/app/[orgId]/settings/access/invitations/page.tsx @@ -1,13 +1,13 @@ import { internal } from "@app/lib/api"; import { authCookieHeader } from "@app/lib/api/cookies"; import { AxiosResponse } from "axios"; -import InvitationsTable, { InvitationRow } from "./InvitationsTable"; +import InvitationsTable, { InvitationRow } from "../../../../../components/InvitationsTable"; import { GetOrgResponse } from "@server/routers/org"; import { cache } from "react"; import OrgProvider from "@app/providers/OrgProvider"; import UserProvider from "@app/providers/UserProvider"; import { verifySession } from "@app/lib/auth/verifySession"; -import AccessPageHeaderAndNav from "../AccessPageHeaderAndNav"; +import AccessPageHeaderAndNav from "../../../../../components/AccessPageHeaderAndNav"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import { getTranslations } from 'next-intl/server'; diff --git a/src/app/[orgId]/settings/access/roles/page.tsx b/src/app/[orgId]/settings/access/roles/page.tsx index 8faedbf8..cffe4ed9 100644 --- a/src/app/[orgId]/settings/access/roles/page.tsx +++ b/src/app/[orgId]/settings/access/roles/page.tsx @@ -5,7 +5,7 @@ import { GetOrgResponse } from "@server/routers/org"; import { cache } from "react"; import OrgProvider from "@app/providers/OrgProvider"; import { ListRolesResponse } from "@server/routers/role"; -import RolesTable, { RoleRow } from "./RolesTable"; +import RolesTable, { RoleRow } from "../../../../../components/RolesTable"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import { getTranslations } from 'next-intl/server'; diff --git a/src/app/[orgId]/settings/access/users/page.tsx b/src/app/[orgId]/settings/access/users/page.tsx index 4a1a1513..ad1214fd 100644 --- a/src/app/[orgId]/settings/access/users/page.tsx +++ b/src/app/[orgId]/settings/access/users/page.tsx @@ -2,13 +2,13 @@ import { internal } from "@app/lib/api"; import { authCookieHeader } from "@app/lib/api/cookies"; import { ListUsersResponse } from "@server/routers/user"; import { AxiosResponse } from "axios"; -import UsersTable, { UserRow } from "./UsersTable"; +import UsersTable, { UserRow } from "../../../../../components/UsersTable"; import { GetOrgResponse } from "@server/routers/org"; import { cache } from "react"; import OrgProvider from "@app/providers/OrgProvider"; import UserProvider from "@app/providers/UserProvider"; import { verifySession } from "@app/lib/auth/verifySession"; -import AccessPageHeaderAndNav from "../AccessPageHeaderAndNav"; +import AccessPageHeaderAndNav from "../../../../../components/AccessPageHeaderAndNav"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import { getTranslations } from 'next-intl/server'; diff --git a/src/app/[orgId]/settings/api-keys/page.tsx b/src/app/[orgId]/settings/api-keys/page.tsx index 188921e5..ca526a7d 100644 --- a/src/app/[orgId]/settings/api-keys/page.tsx +++ b/src/app/[orgId]/settings/api-keys/page.tsx @@ -2,7 +2,7 @@ import { internal } from "@app/lib/api"; import { authCookieHeader } from "@app/lib/api/cookies"; import { AxiosResponse } from "axios"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; -import OrgApiKeysTable, { OrgApiKeyRow } from "./OrgApiKeysTable"; +import OrgApiKeysTable, { OrgApiKeyRow } from "../../../../components/OrgApiKeysTable"; import { ListOrgApiKeysResponse } from "@server/routers/apiKeys"; import { getTranslations } from 'next-intl/server'; @@ -15,7 +15,7 @@ export const dynamic = "force-dynamic"; export default async function ApiKeysPage(props: ApiKeyPageProps) { const params = await props.params; const t = await getTranslations(); - + let apiKeys: ListOrgApiKeysResponse["apiKeys"] = []; try { const res = await internal.get>( diff --git a/src/app/[orgId]/settings/clients/[clientId]/layout.tsx b/src/app/[orgId]/settings/clients/[clientId]/layout.tsx index d137b00c..84a5f78f 100644 --- a/src/app/[orgId]/settings/clients/[clientId]/layout.tsx +++ b/src/app/[orgId]/settings/clients/[clientId]/layout.tsx @@ -3,7 +3,7 @@ import { AxiosResponse } from "axios"; import { authCookieHeader } from "@app/lib/api/cookies"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import { GetClientResponse } from "@server/routers/client"; -import ClientInfoCard from "./ClientInfoCard"; +import ClientInfoCard from "../../../../../components/ClientInfoCard"; import ClientProvider from "@app/providers/ClientProvider"; import { redirect } from "next/navigation"; import { HorizontalTabs } from "@app/components/HorizontalTabs"; diff --git a/src/app/[orgId]/settings/clients/page.tsx b/src/app/[orgId]/settings/clients/page.tsx index 83cc11e3..994b1d56 100644 --- a/src/app/[orgId]/settings/clients/page.tsx +++ b/src/app/[orgId]/settings/clients/page.tsx @@ -1,10 +1,10 @@ import { internal } from "@app/lib/api"; import { authCookieHeader } from "@app/lib/api/cookies"; import { AxiosResponse } from "axios"; -import { ClientRow } from "./ClientsTable"; +import { ClientRow } from "../../../../components/ClientsTable"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import { ListClientsResponse } from "@server/routers/client"; -import ClientsTable from "./ClientsTable"; +import ClientsTable from "../../../../components/ClientsTable"; type ClientsPageProps = { params: Promise<{ orgId: string }>; diff --git a/src/app/[orgId]/settings/domains/page.tsx b/src/app/[orgId]/settings/domains/page.tsx index c85fe10d..cb587d92 100644 --- a/src/app/[orgId]/settings/domains/page.tsx +++ b/src/app/[orgId]/settings/domains/page.tsx @@ -2,7 +2,7 @@ import { internal } from "@app/lib/api"; import { authCookieHeader } from "@app/lib/api/cookies"; import { AxiosResponse } from "axios"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; -import DomainsTable, { DomainRow } from "./DomainsTable"; +import DomainsTable, { DomainRow } from "../../../../components/DomainsTable"; import { getTranslations } from "next-intl/server"; import { cache } from "react"; import { GetOrgResponse } from "@server/routers/org"; diff --git a/src/app/[orgId]/settings/resources/[resourceId]/authentication/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/authentication/page.tsx index 9bb9919a..4705550e 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/authentication/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/authentication/page.tsx @@ -27,8 +27,8 @@ import { } from "@app/components/ui/form"; import { ListUsersResponse } from "@server/routers/user"; import { Binary, Key } from "lucide-react"; -import SetResourcePasswordForm from "./SetResourcePasswordForm"; -import SetResourcePincodeForm from "./SetResourcePincodeForm"; +import SetResourcePasswordForm from "../../../../../../components/SetResourcePasswordForm"; +import SetResourcePincodeForm from "../../../../../../components/SetResourcePincodeForm"; import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { diff --git a/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx index ce8f29a7..e2bab1cb 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx @@ -54,7 +54,7 @@ import DomainPicker from "@app/components/DomainPicker"; import { Globe } from "lucide-react"; import { build } from "@server/build"; import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils"; -import { DomainRow } from "../../../domains/DomainsTable"; +import { DomainRow } from "../../../../../../components/DomainsTable"; import { toASCII, toUnicode } from "punycode"; export default function GeneralForm() { @@ -160,7 +160,7 @@ export default function GeneralForm() { const rawDomains = res.data.data.domains as DomainRow[]; const domains = rawDomains.map((domain) => ({ ...domain, - baseDomain: toUnicode(domain.baseDomain), + baseDomain: toUnicode(domain.baseDomain), })); setBaseDomains(domains); setFormKey((key) => key + 1); diff --git a/src/app/[orgId]/settings/resources/[resourceId]/layout.tsx b/src/app/[orgId]/settings/resources/[resourceId]/layout.tsx index f6d4b3c0..8a5d0997 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/layout.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/layout.tsx @@ -12,7 +12,7 @@ import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import { GetOrgResponse } from "@server/routers/org"; import OrgProvider from "@app/providers/OrgProvider"; import { cache } from "react"; -import ResourceInfoBox from "./ResourceInfoBox"; +import ResourceInfoBox from "../../../../../components/ResourceInfoBox"; import { GetSiteResponse } from "@server/routers/site"; import { getTranslations } from 'next-intl/server'; diff --git a/src/app/[orgId]/settings/resources/create/page.tsx b/src/app/[orgId]/settings/resources/create/page.tsx index 782b3135..c28f5a5f 100644 --- a/src/app/[orgId]/settings/resources/create/page.tsx +++ b/src/app/[orgId]/settings/resources/create/page.tsx @@ -90,7 +90,7 @@ import { ListTargetsResponse } from "@server/routers/target"; import { DockerManager, DockerState } from "@app/lib/docker"; import { parseHostTarget } from "@app/lib/parseHostTarget"; import { toASCII, toUnicode } from 'punycode'; -import { DomainRow } from "../../domains/DomainsTable"; +import { DomainRow } from "../../../../../components/DomainsTable"; const baseResourceFormSchema = z.object({ name: z.string().min(1).max(255), diff --git a/src/app/[orgId]/settings/resources/page.tsx b/src/app/[orgId]/settings/resources/page.tsx index f8ef5397..b1b907e6 100644 --- a/src/app/[orgId]/settings/resources/page.tsx +++ b/src/app/[orgId]/settings/resources/page.tsx @@ -3,7 +3,7 @@ import { authCookieHeader } from "@app/lib/api/cookies"; import ResourcesTable, { ResourceRow, InternalResourceRow -} from "./ResourcesTable"; +} from "../../../../components/ResourcesTable"; import { AxiosResponse } from "axios"; import { ListResourcesResponse } from "@server/routers/resource"; import { ListAllSiteResourcesByOrgResponse } from "@server/routers/siteResource"; diff --git a/src/app/[orgId]/settings/share-links/CreateShareLinkForm.tsx b/src/app/[orgId]/settings/share-links/CreateShareLinkForm.tsx index 18c989ab..e3ba3f17 100644 --- a/src/app/[orgId]/settings/share-links/CreateShareLinkForm.tsx +++ b/src/app/[orgId]/settings/share-links/CreateShareLinkForm.tsx @@ -58,14 +58,14 @@ import { CheckIcon, ChevronsUpDown } from "lucide-react"; import { Checkbox } from "@app/components/ui/checkbox"; import { GenerateAccessTokenResponse } from "@server/routers/accessToken"; import { constructShareLink } from "@app/lib/shareLinks"; -import { ShareLinkRow } from "./ShareLinksTable"; +import { ShareLinkRow } from "@app/components/ShareLinksTable"; import { QRCodeCanvas, QRCodeSVG } from "qrcode.react"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@app/components/ui/collapsible"; -import AccessTokenSection from "./AccessTokenUsage"; +import AccessTokenSection from "@app/components/AccessTokenUsage"; import { useTranslations } from "next-intl"; import { toUnicode } from 'punycode'; diff --git a/src/app/[orgId]/settings/share-links/ShareLinksTable.tsx b/src/app/[orgId]/settings/share-links/ShareLinksTable.tsx index 41768255..2943311f 100644 --- a/src/app/[orgId]/settings/share-links/ShareLinksTable.tsx +++ b/src/app/[orgId]/settings/share-links/ShareLinksTable.tsx @@ -1,7 +1,7 @@ "use client"; import { ColumnDef } from "@tanstack/react-table"; -import { ShareLinksDataTable } from "./ShareLinksDataTable"; +import { ShareLinksDataTable } from "@app/components/ShareLinksDataTable"; import { DropdownMenu, DropdownMenuContent, @@ -31,7 +31,7 @@ import { useEnvContext } from "@app/hooks/useEnvContext"; import { ArrayElement } from "@server/types/ArrayElement"; import { ListAccessTokensResponse } from "@server/routers/accessToken"; import moment from "moment"; -import CreateShareLinkForm from "./CreateShareLinkForm"; +import CreateShareLinkForm from "@app/components/CreateShareLinkForm"; import { constructShareLink } from "@app/lib/shareLinks"; import { useTranslations } from "next-intl"; diff --git a/src/app/[orgId]/settings/share-links/page.tsx b/src/app/[orgId]/settings/share-links/page.tsx index e4efabd9..be561acd 100644 --- a/src/app/[orgId]/settings/share-links/page.tsx +++ b/src/app/[orgId]/settings/share-links/page.tsx @@ -7,8 +7,8 @@ import { cache } from "react"; import { GetOrgResponse } from "@server/routers/org"; import OrgProvider from "@app/providers/OrgProvider"; import { ListAccessTokensResponse } from "@server/routers/accessToken"; -import ShareLinksTable, { ShareLinkRow } from "./ShareLinksTable"; -import ShareableLinksSplash from "./ShareLinksSplash"; +import ShareLinksTable, { ShareLinkRow } from "../../../../components/ShareLinksTable"; +import ShareableLinksSplash from "../../../../components/ShareLinksSplash"; import { getTranslations } from "next-intl/server"; type ShareLinksPageProps = { diff --git a/src/app/[orgId]/settings/sites/[niceId]/layout.tsx b/src/app/[orgId]/settings/sites/[niceId]/layout.tsx index 597cc852..039deebb 100644 --- a/src/app/[orgId]/settings/sites/[niceId]/layout.tsx +++ b/src/app/[orgId]/settings/sites/[niceId]/layout.tsx @@ -6,7 +6,7 @@ import { redirect } from "next/navigation"; import { authCookieHeader } from "@app/lib/api/cookies"; import { HorizontalTabs } from "@app/components/HorizontalTabs"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; -import SiteInfoCard from "./SiteInfoCard"; +import SiteInfoCard from "../../../../../components/SiteInfoCard"; import { getTranslations } from "next-intl/server"; interface SettingsLayoutProps { diff --git a/src/app/[orgId]/settings/sites/page.tsx b/src/app/[orgId]/settings/sites/page.tsx index 10bcad53..a854083c 100644 --- a/src/app/[orgId]/settings/sites/page.tsx +++ b/src/app/[orgId]/settings/sites/page.tsx @@ -2,9 +2,9 @@ import { internal } from "@app/lib/api"; import { authCookieHeader } from "@app/lib/api/cookies"; import { ListSitesResponse } from "@server/routers/site"; import { AxiosResponse } from "axios"; -import SitesTable, { SiteRow } from "./SitesTable"; +import SitesTable, { SiteRow } from "../../../../components/SitesTable"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; -import SitesSplashCard from "./SitesSplashCard"; +import SitesSplashCard from "../../../../components/SitesSplashCard"; import { getTranslations } from "next-intl/server"; type SitesPageProps = { diff --git a/src/app/admin/api-keys/page.tsx b/src/app/admin/api-keys/page.tsx index 22607f2f..e518911f 100644 --- a/src/app/admin/api-keys/page.tsx +++ b/src/app/admin/api-keys/page.tsx @@ -3,7 +3,7 @@ import { authCookieHeader } from "@app/lib/api/cookies"; import { AxiosResponse } from "axios"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import { ListRootApiKeysResponse } from "@server/routers/apiKeys"; -import ApiKeysTable, { ApiKeyRow } from "./ApiKeysTable"; +import ApiKeysTable, { ApiKeyRow } from "../../../components/ApiKeysTable"; import { getTranslations } from "next-intl/server"; type ApiKeyPageProps = {}; diff --git a/src/app/admin/idp/[idpId]/policies/page.tsx b/src/app/admin/idp/[idpId]/policies/page.tsx index aadd6eb8..01b186bf 100644 --- a/src/app/admin/idp/[idpId]/policies/page.tsx +++ b/src/app/admin/idp/[idpId]/policies/page.tsx @@ -31,7 +31,7 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; import { InfoIcon, ExternalLink, CheckIcon } from "lucide-react"; -import PolicyTable, { PolicyRow } from "./PolicyTable"; +import PolicyTable, { PolicyRow } from "../../../../../components/PolicyTable"; import { AxiosResponse } from "axios"; import { ListOrgsResponse } from "@server/routers/org"; import { diff --git a/src/app/admin/idp/page.tsx b/src/app/admin/idp/page.tsx index 4db77785..fef0990c 100644 --- a/src/app/admin/idp/page.tsx +++ b/src/app/admin/idp/page.tsx @@ -2,7 +2,7 @@ import { internal } from "@app/lib/api"; import { authCookieHeader } from "@app/lib/api/cookies"; import { AxiosResponse } from "axios"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; -import IdpTable, { IdpRow } from "./AdminIdpTable"; +import IdpTable, { IdpRow } from "../../../components/AdminIdpTable"; import { getTranslations } from "next-intl/server"; export default async function IdpPage() { @@ -16,7 +16,7 @@ export default async function IdpPage() { } catch (e) { console.error(e); } - + const t = await getTranslations(); return ( diff --git a/src/app/admin/users/AdminUsersTable.tsx b/src/app/admin/users/AdminUsersTable.tsx index 6c5e4613..8e75ff24 100644 --- a/src/app/admin/users/AdminUsersTable.tsx +++ b/src/app/admin/users/AdminUsersTable.tsx @@ -1,7 +1,7 @@ "use client"; import { ColumnDef } from "@tanstack/react-table"; -import { UsersDataTable } from "./AdminUsersDataTable"; +import { UsersDataTable } from "@app/components/AdminUsersDataTable"; import { Button } from "@app/components/ui/button"; import { ArrowRight, ArrowUpDown, MoreHorizontal } from "lucide-react"; import { useRouter } from "next/navigation"; diff --git a/src/app/admin/users/page.tsx b/src/app/admin/users/page.tsx index e9673374..bf6547a3 100644 --- a/src/app/admin/users/page.tsx +++ b/src/app/admin/users/page.tsx @@ -3,7 +3,7 @@ import { authCookieHeader } from "@app/lib/api/cookies"; import { AxiosResponse } from "axios"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import { AdminListUsersResponse } from "@server/routers/user/adminListUsers"; -import UsersTable, { GlobalUserRow } from "./AdminUsersTable"; +import UsersTable, { GlobalUserRow } from "../../../components/AdminUsersTable"; import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; import { InfoIcon } from "lucide-react"; import { getTranslations } from "next-intl/server"; diff --git a/src/app/auth/idp/[idpId]/oidc/callback/page.tsx b/src/app/auth/idp/[idpId]/oidc/callback/page.tsx index 1c0f8125..2ff8d09a 100644 --- a/src/app/auth/idp/[idpId]/oidc/callback/page.tsx +++ b/src/app/auth/idp/[idpId]/oidc/callback/page.tsx @@ -1,5 +1,5 @@ import { cookies } from "next/headers"; -import ValidateOidcToken from "./ValidateOidcToken"; +import ValidateOidcToken from "@app/components/ValidateOidcToken"; import { cache } from "react"; import { priv } from "@app/lib/api"; import { AxiosResponse } from "axios"; diff --git a/src/app/auth/login/page.tsx b/src/app/auth/login/page.tsx index cad0ce58..e2505303 100644 --- a/src/app/auth/login/page.tsx +++ b/src/app/auth/login/page.tsx @@ -2,7 +2,7 @@ import { verifySession } from "@app/lib/auth/verifySession"; import Link from "next/link"; import { redirect } from "next/navigation"; import { cache } from "react"; -import DashboardLoginForm from "./DashboardLoginForm"; +import DashboardLoginForm from "@app/components/DashboardLoginForm"; import { Mail } from "lucide-react"; import { pullEnv } from "@app/lib/pullEnv"; import { cleanRedirect } from "@app/lib/cleanRedirect"; diff --git a/src/app/auth/reset-password/ResetPasswordForm.tsx b/src/app/auth/reset-password/ResetPasswordForm.tsx index 596afb99..3d456bd9 100644 --- a/src/app/auth/reset-password/ResetPasswordForm.tsx +++ b/src/app/auth/reset-password/ResetPasswordForm.tsx @@ -35,7 +35,7 @@ import { ResetPasswordResponse } from "@server/routers/auth"; import { Loader2 } from "lucide-react"; -import { Alert, AlertDescription } from "../../../components/ui/alert"; +import { Alert, AlertDescription } from "@app/components/ui/alert"; import { toast } from "@app/hooks/useToast"; import { useRouter } from "next/navigation"; import { formatAxiosError } from "@app/lib/api"; @@ -210,7 +210,7 @@ export default function ResetPasswordForm({ } catch (verificationError) { console.error("Failed to send verification code:", verificationError); } - + if (redirect) { router.push(`/auth/verify-email?redirect=${redirect}`); } else { @@ -254,8 +254,8 @@ export default function ResetPasswordForm({ {quickstart ? t('completeAccountSetup') : t('passwordReset')} - {quickstart - ? t('completeAccountSetupDescription') + {quickstart + ? t('completeAccountSetupDescription') : t('passwordResetDescription') } @@ -282,8 +282,8 @@ export default function ResetPasswordForm({ - {quickstart - ? t('accountSetupSent') + {quickstart + ? t('accountSetupSent') : t('passwordResetSent') } @@ -325,8 +325,8 @@ export default function ResetPasswordForm({ render={({ field }) => ( - {quickstart - ? t('accountSetupCode') + {quickstart + ? t('accountSetupCode') : t('passwordResetCode') } @@ -338,8 +338,8 @@ export default function ResetPasswordForm({ - {quickstart - ? t('accountSetupCodeDescription') + {quickstart + ? t('accountSetupCodeDescription') : t('passwordResetCodeDescription') } @@ -354,8 +354,8 @@ export default function ResetPasswordForm({ render={({ field }) => ( - {quickstart - ? t('passwordCreate') + {quickstart + ? t('passwordCreate') : t('passwordNew') } @@ -375,8 +375,8 @@ export default function ResetPasswordForm({ render={({ field }) => ( - {quickstart - ? t('passwordCreateConfirm') + {quickstart + ? t('passwordCreateConfirm') : t('passwordNewConfirm') } @@ -490,8 +490,8 @@ export default function ResetPasswordForm({ {isSubmitting && ( )} - {quickstart - ? t('accountSetupSubmit') + {quickstart + ? t('accountSetupSubmit') : t('passwordResetSubmit') } diff --git a/src/app/auth/reset-password/page.tsx b/src/app/auth/reset-password/page.tsx index f06c7c4c..490f89f7 100644 --- a/src/app/auth/reset-password/page.tsx +++ b/src/app/auth/reset-password/page.tsx @@ -1,7 +1,7 @@ import { verifySession } from "@app/lib/auth/verifySession"; import { redirect } from "next/navigation"; import { cache } from "react"; -import ResetPasswordForm from "./ResetPasswordForm"; +import ResetPasswordForm from "@app/components/ResetPasswordForm"; import Link from "next/link"; import { cleanRedirect } from "@app/lib/cleanRedirect"; import { getTranslations } from "next-intl/server"; diff --git a/src/app/auth/resource/[resourceId]/page.tsx b/src/app/auth/resource/[resourceId]/page.tsx index 347d3586..25580ee7 100644 --- a/src/app/auth/resource/[resourceId]/page.tsx +++ b/src/app/auth/resource/[resourceId]/page.tsx @@ -2,20 +2,20 @@ import { GetResourceAuthInfoResponse, GetExchangeTokenResponse } from "@server/routers/resource"; -import ResourceAuthPortal from "./ResourceAuthPortal"; +import ResourceAuthPortal from "@app/components/ResourceAuthPortal"; import { internal, priv } from "@app/lib/api"; import { AxiosResponse } from "axios"; import { authCookieHeader } from "@app/lib/api/cookies"; import { cache } from "react"; import { verifySession } from "@app/lib/auth/verifySession"; import { redirect } from "next/navigation"; -import ResourceNotFound from "./ResourceNotFound"; -import ResourceAccessDenied from "./ResourceAccessDenied"; -import AccessToken from "./AccessToken"; +import ResourceNotFound from "@app/components/ResourceNotFound"; +import ResourceAccessDenied from "@app/components/ResourceAccessDenied"; +import AccessToken from "@app/components/AccessToken"; import { pullEnv } from "@app/lib/pullEnv"; import { LoginFormIDP } from "@app/components/LoginForm"; import { ListIdpsResponse } from "@server/routers/idp"; -import AutoLoginHandler from "./AutoLoginHandler"; +import AutoLoginHandler from "@app/components/AutoLoginHandler"; export const dynamic = "force-dynamic"; diff --git a/src/app/auth/signup/page.tsx b/src/app/auth/signup/page.tsx index 673e69bf..b4f4fddd 100644 --- a/src/app/auth/signup/page.tsx +++ b/src/app/auth/signup/page.tsx @@ -1,4 +1,4 @@ -import SignupForm from "@app/app/auth/signup/SignupForm"; +import SignupForm from "@app/components/SignupForm"; import { verifySession } from "@app/lib/auth/verifySession"; import { cleanRedirect } from "@app/lib/cleanRedirect"; import { pullEnv } from "@app/lib/pullEnv"; @@ -11,7 +11,7 @@ import { getTranslations } from "next-intl/server"; export const dynamic = "force-dynamic"; export default async function Page(props: { - searchParams: Promise<{ + searchParams: Promise<{ redirect: string | undefined; email: string | undefined; }>; diff --git a/src/app/auth/verify-email/page.tsx b/src/app/auth/verify-email/page.tsx index 10ad809f..c549abf0 100644 --- a/src/app/auth/verify-email/page.tsx +++ b/src/app/auth/verify-email/page.tsx @@ -1,4 +1,4 @@ -import VerifyEmailForm from "@app/app/auth/verify-email/VerifyEmailForm"; +import VerifyEmailForm from "@app/components/VerifyEmailForm"; import { verifySession } from "@app/lib/auth/verifySession"; import { cleanRedirect } from "@app/lib/cleanRedirect"; import { pullEnv } from "@app/lib/pullEnv"; diff --git a/src/app/invite/page.tsx b/src/app/invite/page.tsx index 2e0c11e2..49c5a2c5 100644 --- a/src/app/invite/page.tsx +++ b/src/app/invite/page.tsx @@ -4,7 +4,7 @@ import { verifySession } from "@app/lib/auth/verifySession"; import { AcceptInviteResponse } from "@server/routers/user"; import { AxiosResponse } from "axios"; import { redirect } from "next/navigation"; -import InviteStatusCard from "./InviteStatusCard"; +import InviteStatusCard from "../../components/InviteStatusCard"; import { formatAxiosError } from "@app/lib/api"; import { getTranslations } from "next-intl/server"; @@ -72,14 +72,14 @@ export default async function InvitePage(props: { const type = cardType(); if (!user && type === "user_does_not_exist") { - const redirectUrl = emailParam + const redirectUrl = emailParam ? `/auth/signup?redirect=/invite?token=${params.token}&email=${encodeURIComponent(emailParam)}` : `/auth/signup?redirect=/invite?token=${params.token}`; redirect(redirectUrl); } if (!user && type === "not_logged_in") { - const redirectUrl = emailParam + const redirectUrl = emailParam ? `/auth/login?redirect=/invite?token=${params.token}&email=${encodeURIComponent(emailParam)}` : `/auth/login?redirect=/invite?token=${params.token}`; redirect(redirectUrl); diff --git a/src/app/s/[accessToken]/page.tsx b/src/app/s/[accessToken]/page.tsx index d28ff7be..61299366 100644 --- a/src/app/s/[accessToken]/page.tsx +++ b/src/app/s/[accessToken]/page.tsx @@ -1,4 +1,4 @@ -import AccessToken from "@app/app/auth/resource/[resourceId]/AccessToken"; +import AccessToken from "@app/components/AccessToken"; export default async function ResourceAuthPage(props: { params: Promise<{ accessToken: string }>; diff --git a/src/app/[orgId]/settings/access/AccessPageHeaderAndNav.tsx b/src/components/AccessPageHeaderAndNav.tsx similarity index 100% rename from src/app/[orgId]/settings/access/AccessPageHeaderAndNav.tsx rename to src/components/AccessPageHeaderAndNav.tsx diff --git a/src/app/auth/resource/[resourceId]/AccessToken.tsx b/src/components/AccessToken.tsx similarity index 100% rename from src/app/auth/resource/[resourceId]/AccessToken.tsx rename to src/components/AccessToken.tsx diff --git a/src/app/[orgId]/settings/share-links/AccessTokenUsage.tsx b/src/components/AccessTokenUsage.tsx similarity index 100% rename from src/app/[orgId]/settings/share-links/AccessTokenUsage.tsx rename to src/components/AccessTokenUsage.tsx diff --git a/src/app/admin/idp/AdminIdpDataTable.tsx b/src/components/AdminIdpDataTable.tsx similarity index 100% rename from src/app/admin/idp/AdminIdpDataTable.tsx rename to src/components/AdminIdpDataTable.tsx diff --git a/src/app/admin/idp/AdminIdpTable.tsx b/src/components/AdminIdpTable.tsx similarity index 99% rename from src/app/admin/idp/AdminIdpTable.tsx rename to src/components/AdminIdpTable.tsx index fa7de6da..9c6c3ac4 100644 --- a/src/app/admin/idp/AdminIdpTable.tsx +++ b/src/components/AdminIdpTable.tsx @@ -1,7 +1,7 @@ "use client"; import { ColumnDef } from "@tanstack/react-table"; -import { IdpDataTable } from "./AdminIdpDataTable"; +import { IdpDataTable } from "@app/components/AdminIdpDataTable"; import { Button } from "@app/components/ui/button"; import { ArrowRight, ArrowUpDown, MoreHorizontal } from "lucide-react"; import { useState } from "react"; diff --git a/src/app/admin/users/AdminUsersDataTable.tsx b/src/components/AdminUsersDataTable.tsx similarity index 100% rename from src/app/admin/users/AdminUsersDataTable.tsx rename to src/components/AdminUsersDataTable.tsx diff --git a/src/components/AdminUsersTable.tsx b/src/components/AdminUsersTable.tsx new file mode 100644 index 00000000..8e75ff24 --- /dev/null +++ b/src/components/AdminUsersTable.tsx @@ -0,0 +1,269 @@ +"use client"; + +import { ColumnDef } from "@tanstack/react-table"; +import { UsersDataTable } from "@app/components/AdminUsersDataTable"; +import { Button } from "@app/components/ui/button"; +import { ArrowRight, ArrowUpDown, MoreHorizontal } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; +import { toast } from "@app/hooks/useToast"; +import { formatAxiosError } from "@app/lib/api"; +import { createApiClient } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useTranslations } from "next-intl"; +import { + DropdownMenu, + DropdownMenuItem, + DropdownMenuContent, + DropdownMenuTrigger +} from "@app/components/ui/dropdown-menu"; + +export type GlobalUserRow = { + id: string; + name: string | null; + username: string; + email: string | null; + type: string; + idpId: number | null; + idpName: string; + dateCreated: string; + twoFactorEnabled: boolean | null; + twoFactorSetupRequested: boolean | null; +}; + +type Props = { + users: GlobalUserRow[]; +}; + +export default function UsersTable({ users }: Props) { + const router = useRouter(); + const t = useTranslations(); + + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [selected, setSelected] = useState(null); + const [rows, setRows] = useState(users); + + const api = createApiClient(useEnvContext()); + + const deleteUser = (id: string) => { + api.delete(`/user/${id}`) + .catch((e) => { + console.error(t("userErrorDelete"), e); + toast({ + variant: "destructive", + title: t("userErrorDelete"), + description: formatAxiosError(e, t("userErrorDelete")) + }); + }) + .then(() => { + router.refresh(); + setIsDeleteModalOpen(false); + + const newRows = rows.filter((row) => row.id !== id); + + setRows(newRows); + }); + }; + + const columns: ColumnDef[] = [ + { + accessorKey: "id", + header: ({ column }) => { + return ( + + ); + } + }, + { + accessorKey: "username", + header: ({ column }) => { + return ( + + ); + } + }, + { + accessorKey: "email", + header: ({ column }) => { + return ( + + ); + } + }, + { + accessorKey: "name", + header: ({ column }) => { + return ( + + ); + } + }, + { + accessorKey: "idpName", + header: ({ column }) => { + return ( + + ); + } + }, + { + accessorKey: "twoFactorEnabled", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const userRow = row.original; + + return ( +
+ + {userRow.twoFactorEnabled || + userRow.twoFactorSetupRequested ? ( + + {t("enabled")} + + ) : ( + {t("disabled")} + )} + +
+ ); + } + }, + { + id: "actions", + cell: ({ row }) => { + const r = row.original; + return ( + <> +
+ + + + + + { + setSelected(r); + setIsDeleteModalOpen(true); + }} + > + {t("delete")} + + + + +
+ + ); + } + } + ]; + + return ( + <> + {selected && ( + { + setIsDeleteModalOpen(val); + setSelected(null); + }} + dialog={ +
+

+ {t("userQuestionRemove", { + selectedUser: + selected?.email || + selected?.name || + selected?.username + })} +

+ +

+ {t("userMessageRemove")} +

+ +

{t("userMessageConfirm")}

+
+ } + buttonText={t("userDeleteConfirm")} + onConfirm={async () => deleteUser(selected!.id)} + string={ + selected.email || selected.name || selected.username + } + title={t("userDeleteServer")} + /> + )} + + + + ); +} diff --git a/src/app/admin/api-keys/ApiKeysDataTable.tsx b/src/components/ApiKeysDataTable.tsx similarity index 100% rename from src/app/admin/api-keys/ApiKeysDataTable.tsx rename to src/components/ApiKeysDataTable.tsx diff --git a/src/app/admin/api-keys/ApiKeysTable.tsx b/src/components/ApiKeysTable.tsx similarity index 98% rename from src/app/admin/api-keys/ApiKeysTable.tsx rename to src/components/ApiKeysTable.tsx index 02aead9e..99094651 100644 --- a/src/app/admin/api-keys/ApiKeysTable.tsx +++ b/src/components/ApiKeysTable.tsx @@ -18,7 +18,7 @@ import { formatAxiosError } from "@app/lib/api"; import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; import moment from "moment"; -import { ApiKeysDataTable } from "./ApiKeysDataTable"; +import { ApiKeysDataTable } from "@app/components/ApiKeysDataTable"; import { useTranslations } from "next-intl"; export type ApiKeyRow = { diff --git a/src/app/auth/resource/[resourceId]/AutoLoginHandler.tsx b/src/components/AutoLoginHandler.tsx similarity index 100% rename from src/app/auth/resource/[resourceId]/AutoLoginHandler.tsx rename to src/components/AutoLoginHandler.tsx diff --git a/src/app/[orgId]/settings/clients/[clientId]/ClientInfoCard.tsx b/src/components/ClientInfoCard.tsx similarity index 100% rename from src/app/[orgId]/settings/clients/[clientId]/ClientInfoCard.tsx rename to src/components/ClientInfoCard.tsx diff --git a/src/app/[orgId]/settings/clients/ClientsDataTable.tsx b/src/components/ClientsDataTable.tsx similarity index 100% rename from src/app/[orgId]/settings/clients/ClientsDataTable.tsx rename to src/components/ClientsDataTable.tsx diff --git a/src/app/[orgId]/settings/clients/ClientsTable.tsx b/src/components/ClientsTable.tsx similarity index 99% rename from src/app/[orgId]/settings/clients/ClientsTable.tsx rename to src/components/ClientsTable.tsx index 7fa81622..fc7c7c84 100644 --- a/src/app/[orgId]/settings/clients/ClientsTable.tsx +++ b/src/components/ClientsTable.tsx @@ -1,7 +1,7 @@ "use client"; import { ColumnDef } from "@tanstack/react-table"; -import { ClientsDataTable } from "./ClientsDataTable"; +import { ClientsDataTable } from "@app/components/ClientsDataTable"; import { DropdownMenu, DropdownMenuContent, diff --git a/src/app/[orgId]/settings/domains/CreateDomainForm.tsx b/src/components/CreateDomainForm.tsx similarity index 100% rename from src/app/[orgId]/settings/domains/CreateDomainForm.tsx rename to src/components/CreateDomainForm.tsx diff --git a/src/app/[orgId]/settings/access/roles/CreateRoleForm.tsx b/src/components/CreateRoleForm.tsx similarity index 100% rename from src/app/[orgId]/settings/access/roles/CreateRoleForm.tsx rename to src/components/CreateRoleForm.tsx diff --git a/src/components/CreateShareLinkForm.tsx b/src/components/CreateShareLinkForm.tsx new file mode 100644 index 00000000..e3ba3f17 --- /dev/null +++ b/src/components/CreateShareLinkForm.tsx @@ -0,0 +1,549 @@ +"use client"; + +import { Button } from "@app/components/ui/button"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@app/components/ui/form"; +import { Input } from "@app/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from "@app/components/ui/select"; +import { toast } from "@app/hooks/useToast"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { AxiosResponse } from "axios"; +import { useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import CopyTextBox from "@app/components/CopyTextBox"; +import { + Credenza, + CredenzaBody, + CredenzaClose, + CredenzaContent, + CredenzaDescription, + CredenzaFooter, + CredenzaHeader, + CredenzaTitle +} from "@app/components/Credenza"; +import { useOrgContext } from "@app/hooks/useOrgContext"; +import { formatAxiosError } from "@app/lib/api"; +import { cn } from "@app/lib/cn"; +import { createApiClient } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { ListResourcesResponse } from "@server/routers/resource"; +import { + Popover, + PopoverContent, + PopoverTrigger +} from "@app/components/ui/popover"; +import { CaretSortIcon } from "@radix-ui/react-icons"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList +} from "@app/components/ui/command"; +import { CheckIcon, ChevronsUpDown } from "lucide-react"; +import { Checkbox } from "@app/components/ui/checkbox"; +import { GenerateAccessTokenResponse } from "@server/routers/accessToken"; +import { constructShareLink } from "@app/lib/shareLinks"; +import { ShareLinkRow } from "@app/components/ShareLinksTable"; +import { QRCodeCanvas, QRCodeSVG } from "qrcode.react"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger +} from "@app/components/ui/collapsible"; +import AccessTokenSection from "@app/components/AccessTokenUsage"; +import { useTranslations } from "next-intl"; +import { toUnicode } from 'punycode'; + +type FormProps = { + open: boolean; + setOpen: (open: boolean) => void; + onCreated?: (result: ShareLinkRow) => void; +}; + +export default function CreateShareLinkForm({ + open, + setOpen, + onCreated +}: FormProps) { + const { org } = useOrgContext(); + + const { env } = useEnvContext(); + const api = createApiClient({ env }); + + const [link, setLink] = useState(null); + const [accessTokenId, setAccessTokenId] = useState(null); + const [accessToken, setAccessToken] = useState(null); + const [loading, setLoading] = useState(false); + const [neverExpire, setNeverExpire] = useState(false); + + const [isOpen, setIsOpen] = useState(false); + const t = useTranslations(); + + const [resources, setResources] = useState< + { + resourceId: number; + name: string; + resourceUrl: string; + }[] + >([]); + + const formSchema = z.object({ + resourceId: z.number({ message: t('shareErrorSelectResource') }), + resourceName: z.string(), + resourceUrl: z.string(), + timeUnit: z.string(), + timeValue: z.coerce.number().int().positive().min(1), + title: z.string().optional() + }); + + const timeUnits = [ + { unit: "minutes", name: t('minutes') }, + { unit: "hours", name: t('hours') }, + { unit: "days", name: t('days') }, + { unit: "weeks", name: t('weeks') }, + { unit: "months", name: t('months') }, + { unit: "years", name: t('years') } + ]; + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + timeUnit: "days", + timeValue: 30, + title: "" + } + }); + + useEffect(() => { + if (!open) { + return; + } + + async function fetchResources() { + const res = await api + .get< + AxiosResponse + >(`/org/${org?.org.orgId}/resources`) + .catch((e) => { + console.error(e); + toast({ + variant: "destructive", + title: t('shareErrorFetchResource'), + description: formatAxiosError( + e, + t('shareErrorFetchResourceDescription') + ) + }); + }); + + if (res?.status === 200) { + setResources( + res.data.data.resources + .filter((r) => { + return r.http; + }) + .map((r) => ({ + resourceId: r.resourceId, + name: r.name, + resourceUrl: `${r.ssl ? "https://" : "http://"}${toUnicode(r.fullDomain || "")}/` + })) + ); + } + } + + fetchResources(); + }, [open]); + + async function onSubmit(values: z.infer) { + setLoading(true); + + // convert time to seconds + let timeInSeconds = values.timeValue; + switch (values.timeUnit) { + case "minutes": + timeInSeconds *= 60; + break; + case "hours": + timeInSeconds *= 60 * 60; + break; + case "days": + timeInSeconds *= 60 * 60 * 24; + break; + case "weeks": + timeInSeconds *= 60 * 60 * 24 * 7; + break; + case "months": + timeInSeconds *= 60 * 60 * 24 * 30; + break; + case "years": + timeInSeconds *= 60 * 60 * 24 * 365; + break; + } + + const res = await api + .post>( + `/resource/${values.resourceId}/access-token`, + { + validForSeconds: neverExpire ? undefined : timeInSeconds, + title: + values.title || + t('shareLink', {resource: (values.resourceName || "Resource" + values.resourceId)}) + } + ) + .catch((e) => { + console.error(e); + toast({ + variant: "destructive", + title: t('shareErrorCreate'), + description: formatAxiosError( + e, + t('shareErrorCreateDescription') + ) + }); + }); + + if (res && res.data.data.accessTokenId) { + const token = res.data.data; + const link = constructShareLink(token.accessToken); + setLink(link); + + setAccessToken(token.accessToken); + setAccessTokenId(token.accessTokenId); + + const resource = resources.find( + (r) => r.resourceId === values.resourceId + ); + + onCreated?.({ + accessTokenId: token.accessTokenId, + resourceId: token.resourceId, + resourceName: values.resourceName, + title: token.title, + createdAt: token.createdAt, + expiresAt: token.expiresAt + }); + } + + setLoading(false); + } + + function getSelectedResourceName(id: number) { + const resource = resources.find((r) => r.resourceId === id); + return `${resource?.name}`; + } + + return ( + <> + { + setOpen(val); + setLink(null); + setLoading(false); + form.reset(); + }} + > + + + {t('shareCreate')} + + {t('shareCreateDescription')} + + + +
+ {!link && ( +
+ + ( + + + {t('resource')} + + + + + + + + + + + + + {t('resourcesNotFound')} + + + {resources.map( + ( + r + ) => ( + { + form.setValue( + "resourceId", + r.resourceId + ); + form.setValue( + "resourceName", + r.name + ); + form.setValue( + "resourceUrl", + r.resourceUrl + ); + }} + > + + {`${r.name}`} + + ) + )} + + + + + + + + )} + /> + + ( + + + {t('shareTitleOptional')} + + + + + + + )} + /> + +
+
+ {t('expireIn')} +
+ ( + + + + + )} + /> + + ( + + + + + + + )} + /> +
+
+ +
+ + setNeverExpire( + val as boolean + ) + } + /> + +
+ +

+ {t('shareExpireDescription')} +

+
+ + + )} + {link && ( +
+

+ {t('shareSeeOnce')} +

+

+ {t('shareAccessHint')} +

+ +
+ +
+ + +
+ +
+
+ + + +
+ + {accessTokenId && accessToken && ( +
+
+ +
+
+ )} +
+
+
+ )} +
+
+ + + + + + +
+
+ + ); +} diff --git a/src/app/[orgId]/settings/resources/[resourceId]/CustomDomainInput.tsx b/src/components/CustomDomainInput.tsx similarity index 100% rename from src/app/[orgId]/settings/resources/[resourceId]/CustomDomainInput.tsx rename to src/components/CustomDomainInput.tsx diff --git a/src/app/auth/login/DashboardLoginForm.tsx b/src/components/DashboardLoginForm.tsx similarity index 100% rename from src/app/auth/login/DashboardLoginForm.tsx rename to src/components/DashboardLoginForm.tsx diff --git a/src/app/[orgId]/settings/access/roles/DeleteRoleForm.tsx b/src/components/DeleteRoleForm.tsx similarity index 99% rename from src/app/[orgId]/settings/access/roles/DeleteRoleForm.tsx rename to src/components/DeleteRoleForm.tsx index f3042f71..3516851b 100644 --- a/src/app/[orgId]/settings/access/roles/DeleteRoleForm.tsx +++ b/src/components/DeleteRoleForm.tsx @@ -34,7 +34,7 @@ import { SelectTrigger, SelectValue } from "@app/components/ui/select"; -import { RoleRow } from "./RolesTable"; +import { RoleRow } from "@app/components/RolesTable"; import { formatAxiosError } from "@app/lib/api"; import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; diff --git a/src/app/[orgId]/settings/domains/DomainsDataTable.tsx b/src/components/DomainsDataTable.tsx similarity index 100% rename from src/app/[orgId]/settings/domains/DomainsDataTable.tsx rename to src/components/DomainsDataTable.tsx diff --git a/src/app/[orgId]/settings/domains/DomainsTable.tsx b/src/components/DomainsTable.tsx similarity index 98% rename from src/app/[orgId]/settings/domains/DomainsTable.tsx rename to src/components/DomainsTable.tsx index 84bc8bc6..5bafe935 100644 --- a/src/app/[orgId]/settings/domains/DomainsTable.tsx +++ b/src/components/DomainsTable.tsx @@ -1,7 +1,7 @@ "use client"; import { ColumnDef } from "@tanstack/react-table"; -import { DomainsDataTable } from "./DomainsDataTable"; +import { DomainsDataTable } from "@app/components/DomainsDataTable"; import { Button } from "@app/components/ui/button"; import { ArrowUpDown } from "lucide-react"; import { useState } from "react"; @@ -12,7 +12,7 @@ import { useEnvContext } from "@app/hooks/useEnvContext"; import { Badge } from "@app/components/ui/badge"; import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; -import CreateDomainForm from "./CreateDomainForm"; +import CreateDomainForm from "@app/components/CreateDomainForm"; import { useToast } from "@app/hooks/useToast"; import { useOrgContext } from "@app/hooks/useOrgContext"; diff --git a/src/components/IdpCreateWizard.tsx b/src/components/IdpCreateWizard.tsx new file mode 100644 index 00000000..beeeff1c --- /dev/null +++ b/src/components/IdpCreateWizard.tsx @@ -0,0 +1,429 @@ +"use client"; + +import { + SettingsContainer, + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionForm, + SettingsSectionGrid, + SettingsSectionHeader, + SettingsSectionTitle +} from "@app/components/Settings"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@app/components/ui/form"; +import { z } from "zod"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Input } from "@app/components/ui/input"; +import { Checkbox } from "@app/components/ui/checkbox"; +import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; +import { InfoIcon, ExternalLink } from "lucide-react"; +import { StrategySelect } from "@app/components/StrategySelect"; +import { SwitchInput } from "@app/components/SwitchInput"; +import { Badge } from "@app/components/ui/badge"; +import { useTranslations } from "next-intl"; + +type CreateIdpFormValues = { + name: string; + type: "oidc"; + clientId: string; + clientSecret: string; + authUrl: string; + tokenUrl: string; + identifierPath: string; + emailPath?: string; + namePath?: string; + scopes: string; + autoProvision: boolean; +}; + +type IdpCreateWizardProps = { + onSubmit: (data: CreateIdpFormValues) => void | Promise; + defaultValues?: Partial; + loading?: boolean; +}; + +export function IdpCreateWizard({ onSubmit, defaultValues, loading = false }: IdpCreateWizardProps) { + const t = useTranslations(); + + const createIdpFormSchema = z.object({ + name: z.string().min(2, { message: t('nameMin', {len: 2}) }), + type: z.enum(["oidc"]), + clientId: z.string().min(1, { message: t('idpClientIdRequired') }), + clientSecret: z.string().min(1, { message: t('idpClientSecretRequired') }), + authUrl: z.string().url({ message: t('idpErrorAuthUrlInvalid') }), + tokenUrl: z.string().url({ message: t('idpErrorTokenUrlInvalid') }), + identifierPath: z + .string() + .min(1, { message: t('idpPathRequired') }), + emailPath: z.string().optional(), + namePath: z.string().optional(), + scopes: z.string().min(1, { message: t('idpScopeRequired') }), + autoProvision: z.boolean().default(false) + }); + + interface ProviderTypeOption { + id: "oidc"; + title: string; + description: string; + } + + const providerTypes: ReadonlyArray = [ + { + id: "oidc", + title: "OAuth2/OIDC", + description: t('idpOidcDescription') + } + ]; + + const form = useForm({ + resolver: zodResolver(createIdpFormSchema), + defaultValues: { + name: "", + type: "oidc", + clientId: "", + clientSecret: "", + authUrl: "", + tokenUrl: "", + identifierPath: "sub", + namePath: "name", + emailPath: "email", + scopes: "openid profile email", + autoProvision: false, + ...defaultValues + } + }); + + const handleSubmit = (data: CreateIdpFormValues) => { + onSubmit(data); + }; + + return ( + + + + + {t('idpTitle')} + + + {t('idpCreateSettingsDescription')} + + + + +
+ + ( + + {t('name')} + + + + + {t('idpDisplayName')} + + + + )} + /> + +
+ { + form.setValue( + "autoProvision", + checked + ); + }} + disabled={loading} + /> +
+ + {t('idpAutoProvisionUsersDescription')} + + + +
+
+
+ + + + + {t('idpType')} + + + {t('idpTypeDescription')} + + + + { + form.setValue("type", value as "oidc"); + }} + cols={3} + /> + + + + {form.watch("type") === "oidc" && ( + + + + + {t('idpOidcConfigure')} + + + {t('idpOidcConfigureDescription')} + + + +
+ + ( + + + {t('idpClientId')} + + + + + + {t('idpClientIdDescription')} + + + + )} + /> + + ( + + + {t('idpClientSecret')} + + + + + + {t('idpClientSecretDescription')} + + + + )} + /> + + ( + + + {t('idpAuthUrl')} + + + + + + {t('idpAuthUrlDescription')} + + + + )} + /> + + ( + + + {t('idpTokenUrl')} + + + + + + {t('idpTokenUrlDescription')} + + + + )} + /> + + + + + + + {t('idpOidcConfigureAlert')} + + + {t('idpOidcConfigureAlertDescription')} + + +
+
+ + + + + {t('idpToken')} + + + {t('idpTokenDescription')} + + + +
+ + + + + {t('idpJmespathAbout')} + + + {t('idpJmespathAboutDescription')}{" "} + + {t('idpJmespathAboutDescriptionLink')}{" "} + + + + + + ( + + + {t('idpJmespathLabel')} + + + + + + {t('idpJmespathLabelDescription')} + + + + )} + /> + + ( + + + {t('idpJmespathEmailPathOptional')} + + + + + + {t('idpJmespathEmailPathOptionalDescription')} + + + + )} + /> + + ( + + + {t('idpJmespathNamePathOptional')} + + + + + + {t('idpJmespathNamePathOptionalDescription')} + + + + )} + /> + + ( + + + {t('idpOidcConfigureScopes')} + + + + + + {t('idpOidcConfigureScopesDescription')} + + + + )} + /> + + +
+
+
+ )} +
+ ); +} diff --git a/src/app/[orgId]/settings/access/invitations/InvitationsDataTable.tsx b/src/components/InvitationsDataTable.tsx similarity index 100% rename from src/app/[orgId]/settings/access/invitations/InvitationsDataTable.tsx rename to src/components/InvitationsDataTable.tsx diff --git a/src/app/[orgId]/settings/access/invitations/InvitationsTable.tsx b/src/components/InvitationsTable.tsx similarity index 97% rename from src/app/[orgId]/settings/access/invitations/InvitationsTable.tsx rename to src/components/InvitationsTable.tsx index dfb3d263..a97220f2 100644 --- a/src/app/[orgId]/settings/access/invitations/InvitationsTable.tsx +++ b/src/components/InvitationsTable.tsx @@ -9,10 +9,10 @@ import { } from "@app/components/ui/dropdown-menu"; import { Button } from "@app/components/ui/button"; import { MoreHorizontal } from "lucide-react"; -import { InvitationsDataTable } from "./InvitationsDataTable"; +import { InvitationsDataTable } from "@app/components/InvitationsDataTable"; import { useState } from "react"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; -import RegenerateInvitationForm from "./RegenerateInvitationForm"; +import RegenerateInvitationForm from "@app/components/RegenerateInvitationForm"; import { useOrgContext } from "@app/hooks/useOrgContext"; import { toast } from "@app/hooks/useToast"; import { createApiClient } from "@app/lib/api"; diff --git a/src/app/invite/InviteStatusCard.tsx b/src/components/InviteStatusCard.tsx similarity index 100% rename from src/app/invite/InviteStatusCard.tsx rename to src/components/InviteStatusCard.tsx diff --git a/src/app/[orgId]/MemberResourcesPortal.tsx b/src/components/MemberResourcesPortal.tsx similarity index 100% rename from src/app/[orgId]/MemberResourcesPortal.tsx rename to src/components/MemberResourcesPortal.tsx diff --git a/src/app/[orgId]/settings/api-keys/OrgApiKeysDataTable.tsx b/src/components/OrgApiKeysDataTable.tsx similarity index 100% rename from src/app/[orgId]/settings/api-keys/OrgApiKeysDataTable.tsx rename to src/components/OrgApiKeysDataTable.tsx diff --git a/src/app/[orgId]/settings/api-keys/OrgApiKeysTable.tsx b/src/components/OrgApiKeysTable.tsx similarity index 98% rename from src/app/[orgId]/settings/api-keys/OrgApiKeysTable.tsx rename to src/components/OrgApiKeysTable.tsx index b503241f..bfe8dfab 100644 --- a/src/app/[orgId]/settings/api-keys/OrgApiKeysTable.tsx +++ b/src/components/OrgApiKeysTable.tsx @@ -1,7 +1,7 @@ "use client"; import { ColumnDef } from "@tanstack/react-table"; -import { OrgApiKeysDataTable } from "./OrgApiKeysDataTable"; +import { OrgApiKeysDataTable } from "@app/components/OrgApiKeysDataTable"; import { DropdownMenu, DropdownMenuContent, diff --git a/src/app/[orgId]/OrganizationLandingCard.tsx b/src/components/OrganizationLandingCard.tsx similarity index 100% rename from src/app/[orgId]/OrganizationLandingCard.tsx rename to src/components/OrganizationLandingCard.tsx diff --git a/src/app/admin/idp/[idpId]/policies/PolicyDataTable.tsx b/src/components/PolicyDataTable.tsx similarity index 100% rename from src/app/admin/idp/[idpId]/policies/PolicyDataTable.tsx rename to src/components/PolicyDataTable.tsx diff --git a/src/app/admin/idp/[idpId]/policies/PolicyTable.tsx b/src/components/PolicyTable.tsx similarity index 100% rename from src/app/admin/idp/[idpId]/policies/PolicyTable.tsx rename to src/components/PolicyTable.tsx diff --git a/src/app/[orgId]/settings/access/invitations/RegenerateInvitationForm.tsx b/src/components/RegenerateInvitationForm.tsx similarity index 100% rename from src/app/[orgId]/settings/access/invitations/RegenerateInvitationForm.tsx rename to src/components/RegenerateInvitationForm.tsx diff --git a/src/components/ResetPasswordForm.tsx b/src/components/ResetPasswordForm.tsx new file mode 100644 index 00000000..faafccf4 --- /dev/null +++ b/src/components/ResetPasswordForm.tsx @@ -0,0 +1,533 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle +} from "@/components/ui/card"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { + InputOTP, + InputOTPGroup, + InputOTPSlot +} from "@/components/ui/input-otp"; +import { AxiosResponse } from "axios"; +import { + RequestPasswordResetBody, + RequestPasswordResetResponse, + ResetPasswordBody, + ResetPasswordResponse +} from "@server/routers/auth"; +import { Loader2 } from "lucide-react"; +import { Alert, AlertDescription } from "./ui/alert"; +import { toast } from "@app/hooks/useToast"; +import { useRouter } from "next/navigation"; +import { formatAxiosError } from "@app/lib/api"; +import { createApiClient } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp"; +import { passwordSchema } from "@server/auth/passwordSchema"; +import { cleanRedirect } from "@app/lib/cleanRedirect"; +import { useTranslations } from "next-intl"; + +const requestSchema = z.object({ + email: z.string().email() +}); + +export type ResetPasswordFormProps = { + emailParam?: string; + tokenParam?: string; + redirect?: string; + quickstart?: boolean; +}; + +export default function ResetPasswordForm({ + emailParam, + tokenParam, + redirect, + quickstart +}: ResetPasswordFormProps) { + const router = useRouter(); + + const [error, setError] = useState(null); + const [successMessage, setSuccessMessage] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + const t = useTranslations(); + + function getState() { + if (emailParam && !tokenParam) { + return "request"; + } + + if (emailParam && tokenParam) { + return "reset"; + } + + return "request"; + } + + const [state, setState] = useState<"request" | "reset" | "mfa">(getState()); + + const api = createApiClient(useEnvContext()); + + const formSchema = z + .object({ + email: z.string().email({ message: t('emailInvalid') }), + token: z.string().min(8, { message: t('tokenInvalid') }), + password: passwordSchema, + confirmPassword: passwordSchema + }) + .refine((data) => data.password === data.confirmPassword, { + path: ["confirmPassword"], + message: t('passwordNotMatch') + }); + + const mfaSchema = z.object({ + code: z.string().length(6, { message: t('pincodeInvalid') }) + }); + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + email: emailParam || "", + token: tokenParam || "", + password: "", + confirmPassword: "" + } + }); + + const mfaForm = useForm>({ + resolver: zodResolver(mfaSchema), + defaultValues: { + code: "" + } + }); + + const requestForm = useForm>({ + resolver: zodResolver(requestSchema), + defaultValues: { + email: emailParam || "" + } + }); + + async function onRequest(data: z.infer) { + const { email } = data; + + setIsSubmitting(true); + + const res = await api + .post>( + "/auth/reset-password/request", + { + email + } as RequestPasswordResetBody + ) + .catch((e) => { + setError(formatAxiosError(e, t('errorOccurred'))); + console.error(t('passwordErrorRequestReset'), e); + setIsSubmitting(false); + }); + + if (res && res.data?.data) { + setError(null); + setState("reset"); + setIsSubmitting(false); + form.setValue("email", email); + } + } + + async function onReset(data: any) { + setIsSubmitting(true); + + const { password, email, token } = form.getValues(); + const { code } = mfaForm.getValues(); + + const res = await api + .post>( + "/auth/reset-password", + { + email, + token, + newPassword: password, + code + } as ResetPasswordBody + ) + .catch((e) => { + setError(formatAxiosError(e, t('errorOccurred'))); + console.error(t('passwordErrorReset'), e); + setIsSubmitting(false); + }); + + console.log(res); + + if (res) { + setError(null); + + if (res.data.data?.codeRequested) { + setState("mfa"); + setIsSubmitting(false); + mfaForm.reset(); + return; + } + + setSuccessMessage(quickstart ? t('accountSetupSuccess') : t('passwordResetSuccess')); + + // Auto-login after successful password reset + try { + const loginRes = await api.post("/auth/login", { + email: form.getValues("email"), + password: form.getValues("password") + }); + + if (loginRes.data.data?.codeRequested) { + if (redirect) { + router.push(`/auth/login?redirect=${redirect}`); + } else { + router.push("/auth/login"); + } + return; + } + + if (loginRes.data.data?.emailVerificationRequired) { + try { + await api.post("/auth/verify-email/request"); + } catch (verificationError) { + console.error("Failed to send verification code:", verificationError); + } + + if (redirect) { + router.push(`/auth/verify-email?redirect=${redirect}`); + } else { + router.push("/auth/verify-email"); + } + return; + } + + // Login successful, redirect + setTimeout(() => { + if (redirect) { + const safe = cleanRedirect(redirect); + router.push(safe); + } else { + router.push("/"); + } + setIsSubmitting(false); + }, 1500); + + } catch (loginError) { + // Auto-login failed, but password reset was successful + console.error("Auto-login failed:", loginError); + setTimeout(() => { + if (redirect) { + const safe = cleanRedirect(redirect); + router.push(safe); + } else { + router.push("/login"); + } + setIsSubmitting(false); + }, 1500); + } + } + } + + return ( +
+ + + + {quickstart ? t('completeAccountSetup') : t('passwordReset')} + + + {quickstart + ? t('completeAccountSetupDescription') + : t('passwordResetDescription') + } + + + +
+ {state === "request" && ( +
+ + ( + + {t('email')} + + + + + + {quickstart + ? t('accountSetupSent') + : t('passwordResetSent') + } + + + )} + /> + + + )} + + {state === "reset" && ( +
+ + ( + + {t('email')} + + + + + + )} + /> + + {!tokenParam && ( + ( + + + {quickstart + ? t('accountSetupCode') + : t('passwordResetCode') + } + + + + + + + {quickstart + ? t('accountSetupCodeDescription') + : t('passwordResetCodeDescription') + } + + + )} + /> + )} + + ( + + + {quickstart + ? t('passwordCreate') + : t('passwordNew') + } + + + + + + + )} + /> + ( + + + {quickstart + ? t('passwordCreateConfirm') + : t('passwordNewConfirm') + } + + + + + + + )} + /> + + + )} + + {state === "mfa" && ( +
+ + ( + + + {t('pincodeAuth')} + + +
+ + + + + + + + + + +
+
+ +
+ )} + /> + + + )} + + {error && ( + + {error} + + )} + + {successMessage && ( + + + {successMessage} + + + )} + +
+ {(state === "reset" || state === "mfa") && ( + + )} + + {state === "request" && ( + + )} + + {state === "mfa" && ( + + )} + + {(state === "mfa" || state === "reset") && ( + + )} +
+
+
+
+
+ ); +} diff --git a/src/app/auth/resource/[resourceId]/ResourceAccessDenied.tsx b/src/components/ResourceAccessDenied.tsx similarity index 100% rename from src/app/auth/resource/[resourceId]/ResourceAccessDenied.tsx rename to src/components/ResourceAccessDenied.tsx diff --git a/src/app/auth/resource/[resourceId]/ResourceAuthPortal.tsx b/src/components/ResourceAuthPortal.tsx similarity index 99% rename from src/app/auth/resource/[resourceId]/ResourceAuthPortal.tsx rename to src/components/ResourceAuthPortal.tsx index 6f14f915..1ab131e3 100644 --- a/src/app/auth/resource/[resourceId]/ResourceAuthPortal.tsx +++ b/src/components/ResourceAuthPortal.tsx @@ -38,7 +38,7 @@ import { AuthWithPasswordResponse, AuthWithWhitelistResponse } from "@server/routers/resource"; -import ResourceAccessDenied from "./ResourceAccessDenied"; +import ResourceAccessDenied from "@app/components/ResourceAccessDenied"; import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { toast } from "@app/hooks/useToast"; diff --git a/src/app/[orgId]/settings/resources/[resourceId]/ResourceInfoBox.tsx b/src/components/ResourceInfoBox.tsx similarity index 100% rename from src/app/[orgId]/settings/resources/[resourceId]/ResourceInfoBox.tsx rename to src/components/ResourceInfoBox.tsx diff --git a/src/app/auth/resource/[resourceId]/ResourceNotFound.tsx b/src/components/ResourceNotFound.tsx similarity index 100% rename from src/app/auth/resource/[resourceId]/ResourceNotFound.tsx rename to src/components/ResourceNotFound.tsx diff --git a/src/app/[orgId]/settings/resources/ResourcesTable.tsx b/src/components/ResourcesTable.tsx similarity index 100% rename from src/app/[orgId]/settings/resources/ResourcesTable.tsx rename to src/components/ResourcesTable.tsx diff --git a/src/app/[orgId]/settings/access/roles/RolesDataTable.tsx b/src/components/RolesDataTable.tsx similarity index 100% rename from src/app/[orgId]/settings/access/roles/RolesDataTable.tsx rename to src/components/RolesDataTable.tsx diff --git a/src/app/[orgId]/settings/access/roles/RolesTable.tsx b/src/components/RolesTable.tsx similarity index 95% rename from src/app/[orgId]/settings/access/roles/RolesTable.tsx rename to src/components/RolesTable.tsx index 40260fb7..e92e71b6 100644 --- a/src/app/[orgId]/settings/access/roles/RolesTable.tsx +++ b/src/components/RolesTable.tsx @@ -13,10 +13,10 @@ import { useState } from "react"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import { useOrgContext } from "@app/hooks/useOrgContext"; import { toast } from "@app/hooks/useToast"; -import { RolesDataTable } from "./RolesDataTable"; +import { RolesDataTable } from "@app/components/RolesDataTable"; import { Role } from "@server/db"; -import CreateRoleForm from "./CreateRoleForm"; -import DeleteRoleForm from "./DeleteRoleForm"; +import CreateRoleForm from "@app/components/CreateRoleForm"; +import DeleteRoleForm from "@app/components/DeleteRoleForm"; import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { useTranslations } from "next-intl"; diff --git a/src/app/[orgId]/settings/resources/[resourceId]/authentication/SetResourcePasswordForm.tsx b/src/components/SetResourcePasswordForm.tsx similarity index 100% rename from src/app/[orgId]/settings/resources/[resourceId]/authentication/SetResourcePasswordForm.tsx rename to src/components/SetResourcePasswordForm.tsx diff --git a/src/app/[orgId]/settings/resources/[resourceId]/authentication/SetResourcePincodeForm.tsx b/src/components/SetResourcePincodeForm.tsx similarity index 100% rename from src/app/[orgId]/settings/resources/[resourceId]/authentication/SetResourcePincodeForm.tsx rename to src/components/SetResourcePincodeForm.tsx diff --git a/src/app/[orgId]/settings/share-links/ShareLinksDataTable.tsx b/src/components/ShareLinksDataTable.tsx similarity index 100% rename from src/app/[orgId]/settings/share-links/ShareLinksDataTable.tsx rename to src/components/ShareLinksDataTable.tsx diff --git a/src/app/[orgId]/settings/share-links/ShareLinksSplash.tsx b/src/components/ShareLinksSplash.tsx similarity index 100% rename from src/app/[orgId]/settings/share-links/ShareLinksSplash.tsx rename to src/components/ShareLinksSplash.tsx diff --git a/src/components/ShareLinksTable.tsx b/src/components/ShareLinksTable.tsx new file mode 100644 index 00000000..2943311f --- /dev/null +++ b/src/components/ShareLinksTable.tsx @@ -0,0 +1,298 @@ +"use client"; + +import { ColumnDef } from "@tanstack/react-table"; +import { ShareLinksDataTable } from "@app/components/ShareLinksDataTable"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from "@app/components/ui/dropdown-menu"; +import { Button } from "@app/components/ui/button"; +import { + Copy, + ArrowRight, + ArrowUpDown, + MoreHorizontal, + Check, + ArrowUpRight, + ShieldOff, + ShieldCheck +} from "lucide-react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +// import CreateResourceForm from "./CreateResourceForm"; +import { useState } from "react"; +import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; +import { formatAxiosError } from "@app/lib/api"; +import { toast } from "@app/hooks/useToast"; +import { createApiClient } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { ArrayElement } from "@server/types/ArrayElement"; +import { ListAccessTokensResponse } from "@server/routers/accessToken"; +import moment from "moment"; +import CreateShareLinkForm from "@app/components/CreateShareLinkForm"; +import { constructShareLink } from "@app/lib/shareLinks"; +import { useTranslations } from "next-intl"; + +export type ShareLinkRow = { + accessTokenId: string; + resourceId: number; + resourceName: string; + title: string | null; + createdAt: number; + expiresAt: number | null; +}; + +type ShareLinksTableProps = { + shareLinks: ShareLinkRow[]; + orgId: string; +}; + +export default function ShareLinksTable({ + shareLinks, + orgId +}: ShareLinksTableProps) { + const router = useRouter(); + const t = useTranslations(); + + const api = createApiClient(useEnvContext()); + + const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); + const [rows, setRows] = useState(shareLinks); + + function formatLink(link: string) { + return link.substring(0, 20) + "..." + link.substring(link.length - 20); + } + + async function deleteSharelink(id: string) { + await api.delete(`/access-token/${id}`).catch((e) => { + toast({ + title: t("shareErrorDelete"), + description: formatAxiosError(e, t("shareErrorDeleteMessage")) + }); + }); + + const newRows = rows.filter((r) => r.accessTokenId !== id); + setRows(newRows); + + toast({ + title: t("shareDeleted"), + description: t("shareDeletedDescription") + }); + } + + const columns: ColumnDef[] = [ + { + accessorKey: "resourceName", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const r = row.original; + return ( + + + + ); + } + }, + { + accessorKey: "title", + header: ({ column }) => { + return ( + + ); + } + }, + // { + // accessorKey: "domain", + // header: "Link", + // cell: ({ row }) => { + // const r = row.original; + // + // const link = constructShareLink( + // r.resourceId, + // r.accessTokenId, + // r.tokenHash + // ); + // + // return ( + //
+ // + // {formatLink(link)} + // + // + //
+ // ); + // } + // }, + { + accessorKey: "createdAt", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const r = row.original; + return moment(r.createdAt).format("lll"); + } + }, + { + accessorKey: "expiresAt", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const r = row.original; + if (r.expiresAt) { + return moment(r.expiresAt).format("lll"); + } + return t("never"); + } + }, + { + id: "delete", + cell: ({ row }) => { + const resourceRow = row.original; + return ( +
+ {/* */} + {/* */} + {/* */} + {/* */} + {/* */} + {/* { */} + {/* deleteSharelink( */} + {/* resourceRow.accessTokenId */} + {/* ); */} + {/* }} */} + {/* > */} + {/* */} + {/* */} + {/* */} + {/* */} + +
+ ); + } + } + ]; + + return ( + <> + { + setRows([val, ...rows]); + }} + /> + + { + setIsCreateModalOpen(true); + }} + /> + + ); +} diff --git a/src/app/auth/signup/SignupForm.tsx b/src/components/SignupForm.tsx similarity index 100% rename from src/app/auth/signup/SignupForm.tsx rename to src/components/SignupForm.tsx diff --git a/src/app/[orgId]/settings/sites/[niceId]/SiteInfoCard.tsx b/src/components/SiteInfoCard.tsx similarity index 100% rename from src/app/[orgId]/settings/sites/[niceId]/SiteInfoCard.tsx rename to src/components/SiteInfoCard.tsx diff --git a/src/app/[orgId]/settings/sites/SitesDataTable.tsx b/src/components/SitesDataTable.tsx similarity index 100% rename from src/app/[orgId]/settings/sites/SitesDataTable.tsx rename to src/components/SitesDataTable.tsx diff --git a/src/app/[orgId]/settings/sites/SitesSplashCard.tsx b/src/components/SitesSplashCard.tsx similarity index 100% rename from src/app/[orgId]/settings/sites/SitesSplashCard.tsx rename to src/components/SitesSplashCard.tsx diff --git a/src/app/[orgId]/settings/sites/SitesTable.tsx b/src/components/SitesTable.tsx similarity index 99% rename from src/app/[orgId]/settings/sites/SitesTable.tsx rename to src/components/SitesTable.tsx index 8387ab7c..f9ac8c0d 100644 --- a/src/app/[orgId]/settings/sites/SitesTable.tsx +++ b/src/components/SitesTable.tsx @@ -1,7 +1,7 @@ "use client"; import { Column, ColumnDef } from "@tanstack/react-table"; -import { SitesDataTable } from "./SitesDataTable"; +import { SitesDataTable } from "@app/components/SitesDataTable"; import { DropdownMenu, DropdownMenuContent, diff --git a/src/app/[orgId]/settings/access/users/UsersDataTable.tsx b/src/components/UsersDataTable.tsx similarity index 100% rename from src/app/[orgId]/settings/access/users/UsersDataTable.tsx rename to src/components/UsersDataTable.tsx diff --git a/src/app/[orgId]/settings/access/users/UsersTable.tsx b/src/components/UsersTable.tsx similarity index 99% rename from src/app/[orgId]/settings/access/users/UsersTable.tsx rename to src/components/UsersTable.tsx index 61567e15..7a93f1ef 100644 --- a/src/app/[orgId]/settings/access/users/UsersTable.tsx +++ b/src/components/UsersTable.tsx @@ -9,7 +9,7 @@ import { } from "@app/components/ui/dropdown-menu"; import { Button } from "@app/components/ui/button"; import { ArrowRight, ArrowUpDown, Crown, MoreHorizontal } from "lucide-react"; -import { UsersDataTable } from "./UsersDataTable"; +import { UsersDataTable } from "@app/components/UsersDataTable"; import { useState } from "react"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import { useOrgContext } from "@app/hooks/useOrgContext"; diff --git a/src/app/auth/idp/[idpId]/oidc/callback/ValidateOidcToken.tsx b/src/components/ValidateOidcToken.tsx similarity index 100% rename from src/app/auth/idp/[idpId]/oidc/callback/ValidateOidcToken.tsx rename to src/components/ValidateOidcToken.tsx diff --git a/src/app/auth/verify-email/VerifyEmailForm.tsx b/src/components/VerifyEmailForm.tsx similarity index 99% rename from src/app/auth/verify-email/VerifyEmailForm.tsx rename to src/components/VerifyEmailForm.tsx index e9761eef..9cf48a2f 100644 --- a/src/app/auth/verify-email/VerifyEmailForm.tsx +++ b/src/components/VerifyEmailForm.tsx @@ -30,7 +30,7 @@ import { import { AxiosResponse } from "axios"; import { VerifyEmailResponse } from "@server/routers/auth"; import { ArrowRight, IdCard, Loader2 } from "lucide-react"; -import { Alert, AlertDescription } from "../../../components/ui/alert"; +import { Alert, AlertDescription } from "./ui/alert"; import { toast } from "@app/hooks/useToast"; import { useRouter } from "next/navigation"; import { formatAxiosError } from "@app/lib/api"; From e8585f2a66ddaa753c117f50137a9b632b894d50 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Thu, 4 Sep 2025 11:27:06 -0700 Subject: [PATCH 004/166] remove special char domain placeholders --- messages/bg-BG.json | 2 +- messages/cs-CZ.json | 2 +- messages/de-DE.json | 2 +- messages/en-US.json | 2 +- messages/es-ES.json | 2 +- messages/fr-FR.json | 2 +- messages/it-IT.json | 2 +- messages/ko-KR.json | 2 +- messages/pl-PL.json | 2 +- messages/ru-RU.json | 2 +- messages/tr-TR.json | 2 +- src/components/CreateDomainForm.tsx | 4 ++-- 12 files changed, 13 insertions(+), 13 deletions(-) diff --git a/messages/bg-BG.json b/messages/bg-BG.json index d17c99f3..a388c4d9 100644 --- a/messages/bg-BG.json +++ b/messages/bg-BG.json @@ -1234,7 +1234,7 @@ "newtUpdateAvailable": "Update Available", "newtUpdateAvailableInfo": "A new version of Newt is available. Please update to the latest version for the best experience.", "domainPickerEnterDomain": "Domain", - "domainPickerPlaceholder": "myapp.example.com, api.v1.mydomain.com, or just myapp", + "domainPickerPlaceholder": "myapp.example.com", "domainPickerDescription": "Enter the full domain of the resource to see available options.", "domainPickerDescriptionSaas": "Enter a full domain, subdomain, or just a name to see available options", "domainPickerTabAll": "All", diff --git a/messages/cs-CZ.json b/messages/cs-CZ.json index 727d9a5e..eae522ee 100644 --- a/messages/cs-CZ.json +++ b/messages/cs-CZ.json @@ -1234,7 +1234,7 @@ "newtUpdateAvailable": "Update Available", "newtUpdateAvailableInfo": "A new version of Newt is available. Please update to the latest version for the best experience.", "domainPickerEnterDomain": "Domain", - "domainPickerPlaceholder": "myapp.example.com, api.v1.mydomain.com, or just myapp", + "domainPickerPlaceholder": "myapp.example.com", "domainPickerDescription": "Enter the full domain of the resource to see available options.", "domainPickerDescriptionSaas": "Enter a full domain, subdomain, or just a name to see available options", "domainPickerTabAll": "All", diff --git a/messages/de-DE.json b/messages/de-DE.json index 54d14c8d..e97ca32d 100644 --- a/messages/de-DE.json +++ b/messages/de-DE.json @@ -1234,7 +1234,7 @@ "newtUpdateAvailable": "Update verfügbar", "newtUpdateAvailableInfo": "Eine neue Version von Newt ist verfügbar. Bitte aktualisieren Sie auf die neueste Version für das beste Erlebnis.", "domainPickerEnterDomain": "Domain", - "domainPickerPlaceholder": "myapp.example.com, api.v1.mydomain.com, oder einfach myapp", + "domainPickerPlaceholder": "myapp.example.com", "domainPickerDescription": "Geben Sie die vollständige Domäne der Ressource ein, um verfügbare Optionen zu sehen.", "domainPickerDescriptionSaas": "Geben Sie eine vollständige Domäne, Subdomäne oder einfach einen Namen ein, um verfügbare Optionen zu sehen", "domainPickerTabAll": "Alle", diff --git a/messages/en-US.json b/messages/en-US.json index d238f73c..90426d84 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1234,7 +1234,7 @@ "newtUpdateAvailable": "Update Available", "newtUpdateAvailableInfo": "A new version of Newt is available. Please update to the latest version for the best experience.", "domainPickerEnterDomain": "Domain", - "domainPickerPlaceholder": "myapp.example.com, api.v1.mydomain.com, or just myapp", + "domainPickerPlaceholder": "myapp.example.com", "domainPickerDescription": "Enter the full domain of the resource to see available options.", "domainPickerDescriptionSaas": "Enter a full domain, subdomain, or just a name to see available options", "domainPickerTabAll": "All", diff --git a/messages/es-ES.json b/messages/es-ES.json index fe8c52d1..a4fb0646 100644 --- a/messages/es-ES.json +++ b/messages/es-ES.json @@ -1234,7 +1234,7 @@ "newtUpdateAvailable": "Nueva actualización disponible", "newtUpdateAvailableInfo": "Hay una nueva versión de Newt disponible. Actualice a la última versión para la mejor experiencia.", "domainPickerEnterDomain": "Dominio", - "domainPickerPlaceholder": "myapp.example.com, api.v1.miDominio.com, o solo myapp", + "domainPickerPlaceholder": "myapp.example.com", "domainPickerDescription": "Ingresa el dominio completo del recurso para ver las opciones disponibles.", "domainPickerDescriptionSaas": "Ingresa un dominio completo, subdominio o simplemente un nombre para ver las opciones disponibles", "domainPickerTabAll": "Todo", diff --git a/messages/fr-FR.json b/messages/fr-FR.json index 7f51a9c8..a7d4a74f 100644 --- a/messages/fr-FR.json +++ b/messages/fr-FR.json @@ -1234,7 +1234,7 @@ "newtUpdateAvailable": "Mise à jour disponible", "newtUpdateAvailableInfo": "Une nouvelle version de Newt est disponible. Veuillez mettre à jour vers la dernière version pour une meilleure expérience.", "domainPickerEnterDomain": "Domaine", - "domainPickerPlaceholder": "myapp.example.com, api.v1.mydomain.com, ou simplement myapp", + "domainPickerPlaceholder": "myapp.example.com", "domainPickerDescription": "Entrez le domaine complet de la ressource pour voir les options disponibles.", "domainPickerDescriptionSaas": "Entrez un domaine complet, un sous-domaine ou juste un nom pour voir les options disponibles", "domainPickerTabAll": "Tous", diff --git a/messages/it-IT.json b/messages/it-IT.json index 9b935609..489a5bcd 100644 --- a/messages/it-IT.json +++ b/messages/it-IT.json @@ -1234,7 +1234,7 @@ "newtUpdateAvailable": "Aggiornamento Disponibile", "newtUpdateAvailableInfo": "È disponibile una nuova versione di Newt. Si prega di aggiornare all'ultima versione per la migliore esperienza.", "domainPickerEnterDomain": "Dominio", - "domainPickerPlaceholder": "myapp.example.com, api.v1.mydomain.com, o semplicemente myapp", + "domainPickerPlaceholder": "myapp.example.com", "domainPickerDescription": "Inserisci il dominio completo della risorsa per vedere le opzioni disponibili.", "domainPickerDescriptionSaas": "Inserisci un dominio completo, un sottodominio o semplicemente un nome per vedere le opzioni disponibili", "domainPickerTabAll": "Tutti", diff --git a/messages/ko-KR.json b/messages/ko-KR.json index 2b9e7b1c..616456da 100644 --- a/messages/ko-KR.json +++ b/messages/ko-KR.json @@ -1234,7 +1234,7 @@ "newtUpdateAvailable": "업데이트 가능", "newtUpdateAvailableInfo": "뉴트의 새 버전이 출시되었습니다. 최상의 경험을 위해 최신 버전으로 업데이트하세요.", "domainPickerEnterDomain": "도메인", - "domainPickerPlaceholder": "myapp.example.com, api.v1.mydomain.com, 또는 그냥 myapp", + "domainPickerPlaceholder": "myapp.example.com, api.v1.mydomain.com", "domainPickerDescription": "리소스의 전체 도메인을 입력하여 사용 가능한 옵션을 확인하십시오.", "domainPickerDescriptionSaas": "전체 도메인, 서브도메인 또는 이름을 입력하여 사용 가능한 옵션을 확인하십시오.", "domainPickerTabAll": "모두", diff --git a/messages/pl-PL.json b/messages/pl-PL.json index 1aee50f2..5a8a1437 100644 --- a/messages/pl-PL.json +++ b/messages/pl-PL.json @@ -1234,7 +1234,7 @@ "newtUpdateAvailable": "Dostępna aktualizacja", "newtUpdateAvailableInfo": "Nowa wersja Newt jest dostępna. Prosimy o aktualizację do najnowszej wersji dla najlepszej pracy.", "domainPickerEnterDomain": "Domena", - "domainPickerPlaceholder": "myapp.example.com, api.v1.mydomain.com lub po prostu myapp", + "domainPickerPlaceholder": "myapp.example.com", "domainPickerDescription": "Wpisz pełną domenę zasobu, aby zobaczyć dostępne opcje.", "domainPickerDescriptionSaas": "Wprowadź pełną domenę, subdomenę lub po prostu nazwę, aby zobaczyć dostępne opcje", "domainPickerTabAll": "Wszystko", diff --git a/messages/ru-RU.json b/messages/ru-RU.json index ffcbe8dc..284f4e63 100644 --- a/messages/ru-RU.json +++ b/messages/ru-RU.json @@ -1234,7 +1234,7 @@ "newtUpdateAvailable": "Доступно обновление", "newtUpdateAvailableInfo": "Доступна новая версия Newt. Пожалуйста, обновитесь до последней версии для лучшего опыта.", "domainPickerEnterDomain": "Домен", - "domainPickerPlaceholder": "myapp.example.com, api.v1.mydomain.com, или просто myapp", + "domainPickerPlaceholder": "myapp.example.com", "domainPickerDescription": "Введите полный домен ресурса, чтобы увидеть доступные опции.", "domainPickerDescriptionSaas": "Введите полный домен, поддомен или просто имя, чтобы увидеть доступные опции", "domainPickerTabAll": "Все", diff --git a/messages/tr-TR.json b/messages/tr-TR.json index 2253dab2..16ed968d 100644 --- a/messages/tr-TR.json +++ b/messages/tr-TR.json @@ -1234,7 +1234,7 @@ "newtUpdateAvailable": "Güncelleme Mevcut", "newtUpdateAvailableInfo": "Newt'in yeni bir versiyonu mevcut. En iyi deneyim için lütfen en son sürüme güncelleyin.", "domainPickerEnterDomain": "Domain", - "domainPickerPlaceholder": "myapp.example.com, api.v1.mydomain.com veya sadece myapp", + "domainPickerPlaceholder": "myapp.example.com", "domainPickerDescription": "Mevcut seçenekleri görmek için kaynağın tam etki alanını girin.", "domainPickerDescriptionSaas": "Mevcut seçenekleri görmek için tam etki alanı, alt etki alanı veya sadece bir isim girin", "domainPickerTabAll": "Tümü", diff --git a/src/components/CreateDomainForm.tsx b/src/components/CreateDomainForm.tsx index e609a8ac..77fdea9c 100644 --- a/src/components/CreateDomainForm.tsx +++ b/src/components/CreateDomainForm.tsx @@ -238,7 +238,7 @@ export default function CreateDomainForm({ {t("domain")} @@ -604,4 +604,4 @@ export default function CreateDomainForm({ ); -} \ No newline at end of file +} From eba7f55a6280ccf8cc85fa6068bc6edc2f4e7965 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Thu, 4 Sep 2025 15:15:10 -0700 Subject: [PATCH 005/166] fix delete idp user --- src/components/UsersTable.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/UsersTable.tsx b/src/components/UsersTable.tsx index 7a93f1ef..a5526303 100644 --- a/src/components/UsersTable.tsx +++ b/src/components/UsersTable.tsx @@ -146,7 +146,7 @@ export default function UsersTable({ users: u }: UsersTableProps) { {`${userRow.username}-${userRow.idpId}` !== - `${user?.username}-${userRow.idpId}` && ( + `${user?.username}-${user?.idpId}` && ( { setIsDeleteModalOpen( From 52b465b2890b890f664d7db40edf57857c298453 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 4 Sep 2025 14:00:25 -0700 Subject: [PATCH 006/166] Fix typo in response --- server/routers/site/pickSiteDefaults.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/routers/site/pickSiteDefaults.ts b/server/routers/site/pickSiteDefaults.ts index 2e705c56..58d44744 100644 --- a/server/routers/site/pickSiteDefaults.ts +++ b/server/routers/site/pickSiteDefaults.ts @@ -139,7 +139,7 @@ export async function pickSiteDefaults( }, success: true, error: false, - message: "Organization retrieved successfully", + message: "Site defaults chosen successfully", status: HttpCode.OK }); } catch (error) { From 16cbe9bb69fb6c1b4e08d210bdc2f32449c364b1 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 4 Sep 2025 14:00:57 -0700 Subject: [PATCH 007/166] Add transaction type --- server/db/pg/driver.ts | 1 + server/db/sqlite/driver.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/server/db/pg/driver.ts b/server/db/pg/driver.ts index 9625867d..c7c292f0 100644 --- a/server/db/pg/driver.ts +++ b/server/db/pg/driver.ts @@ -50,3 +50,4 @@ function createDb() { export const db = createDb(); export default db; +export type Transaction = Parameters[0]>[0]; \ No newline at end of file diff --git a/server/db/sqlite/driver.ts b/server/db/sqlite/driver.ts index 124bd885..6369c268 100644 --- a/server/db/sqlite/driver.ts +++ b/server/db/sqlite/driver.ts @@ -18,6 +18,7 @@ function createDb() { export const db = createDb(); export default db; +export type Transaction = Parameters[0]>[0]; function checkFileExists(filePath: string): boolean { try { From c1ba993ac844a9fb6946cca88282ab12de6f0a81 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Thu, 4 Sep 2025 15:23:51 -0700 Subject: [PATCH 008/166] scope user id check to idp in create idp user --- server/routers/user/createOrgUser.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/server/routers/user/createOrgUser.ts b/server/routers/user/createOrgUser.ts index 4419772a..5193e8fa 100644 --- a/server/routers/user/createOrgUser.ts +++ b/server/routers/user/createOrgUser.ts @@ -141,7 +141,12 @@ export async function createOrgUser( const [existingUser] = await trx .select() .from(users) - .where(eq(users.username, username)); + .where( + and( + eq(users.username, username), + eq(users.idpId, idpId) + ) + ); if (existingUser) { const [existingOrgUser] = await trx From be2c91415a44c40483f1f09ccc0e0ffc02833c53 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Thu, 4 Sep 2025 17:45:54 -0700 Subject: [PATCH 009/166] add oidc variant --- server/db/pg/schema.ts | 1 + server/db/sqlite/schema.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/server/db/pg/schema.ts b/server/db/pg/schema.ts index 624b4a61..cf2deb24 100644 --- a/server/db/pg/schema.ts +++ b/server/db/pg/schema.ts @@ -459,6 +459,7 @@ export const idpOidcConfig = pgTable("idpOidcConfig", { idpId: integer("idpId") .notNull() .references(() => idp.idpId, { onDelete: "cascade" }), + variant: varchar("variant").notNull().default("generic"), clientId: varchar("clientId").notNull(), clientSecret: varchar("clientSecret").notNull(), authUrl: varchar("authUrl").notNull(), diff --git a/server/db/sqlite/schema.ts b/server/db/sqlite/schema.ts index 971c1841..d29c36b2 100644 --- a/server/db/sqlite/schema.ts +++ b/server/db/sqlite/schema.ts @@ -595,6 +595,7 @@ export const idpOidcConfig = sqliteTable("idpOidcConfig", { idpOauthConfigId: integer("idpOauthConfigId").primaryKey({ autoIncrement: true }), + variant: text("variant").notNull().default("generic"), idpId: integer("idpId") .notNull() .references(() => idp.idpId, { onDelete: "cascade" }), From adc57375f2ad3fc5f36fbc259ec1248a32253ae2 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 4 Sep 2025 17:59:59 -0700 Subject: [PATCH 010/166] Pass in db to pickPort --- server/routers/target/createTarget.ts | 2 +- server/routers/target/helpers.ts | 6 +++--- server/routers/target/updateTarget.ts | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/server/routers/target/createTarget.ts b/server/routers/target/createTarget.ts index 7a3acd55..f58c236e 100644 --- a/server/routers/target/createTarget.ts +++ b/server/routers/target/createTarget.ts @@ -161,7 +161,7 @@ export async function createTarget( ); } - const { internalPort, targetIps } = await pickPort(site.siteId!); + const { internalPort, targetIps } = await pickPort(site.siteId!, db); if (!internalPort) { return next( diff --git a/server/routers/target/helpers.ts b/server/routers/target/helpers.ts index 4935d28a..13b2ee46 100644 --- a/server/routers/target/helpers.ts +++ b/server/routers/target/helpers.ts @@ -1,10 +1,10 @@ -import { db } from "@server/db"; +import { db, Transaction } from "@server/db"; import { resources, targets } from "@server/db"; import { eq } from "drizzle-orm"; const currentBannedPorts: number[] = []; -export async function pickPort(siteId: number): Promise<{ +export async function pickPort(siteId: number, trx: Transaction | typeof db): Promise<{ internalPort: number; targetIps: string[]; }> { @@ -12,7 +12,7 @@ export async function pickPort(siteId: number): Promise<{ const targetIps: string[] = []; const targetInternalPorts: number[] = []; - const targetsRes = await db + const targetsRes = await trx .select() .from(targets) .where(eq(targets.siteId, siteId)); diff --git a/server/routers/target/updateTarget.ts b/server/routers/target/updateTarget.ts index 67d9a8df..47300619 100644 --- a/server/routers/target/updateTarget.ts +++ b/server/routers/target/updateTarget.ts @@ -153,7 +153,7 @@ export async function updateTarget( ); } - const { internalPort, targetIps } = await pickPort(site.siteId!); + const { internalPort, targetIps } = await pickPort(site.siteId!, db); if (!internalPort) { return next( From dba4918edfa6798dd54d77c53ea25462b35f23be Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Thu, 4 Sep 2025 18:02:29 -0700 Subject: [PATCH 011/166] add optional icon to strategy select --- src/components/StrategySelect.tsx | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/src/components/StrategySelect.tsx b/src/components/StrategySelect.tsx index b431b96c..374d1bae 100644 --- a/src/components/StrategySelect.tsx +++ b/src/components/StrategySelect.tsx @@ -2,13 +2,14 @@ import { cn } from "@app/lib/cn"; import { RadioGroup, RadioGroupItem } from "./ui/radio-group"; -import { useState } from "react"; +import { useState, ReactNode } from "react"; export interface StrategyOption { - id: TValue; +id: TValue; title: string; description: string; disabled?: boolean; + icon?: ReactNode; } interface StrategySelectProps { @@ -58,10 +59,17 @@ export function StrategySelect({ disabled={option.disabled} className="absolute left-4 top-5 h-4 w-4 border-primary text-primary" /> -
-
{option.title}
-
- {option.description} +
+ {option.icon && ( +
+ {option.icon} +
+ )} +
+
{option.title}
+
+ {option.description} +
From ce320ca2cf452bb8f7963a0210d7cce55ce9d23f Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Thu, 4 Sep 2025 20:17:59 -0700 Subject: [PATCH 012/166] increase telemetry report interval --- server/db/pg/schema.ts | 2 +- server/db/sqlite/schema.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/server/db/pg/schema.ts b/server/db/pg/schema.ts index cf2deb24..c0b81146 100644 --- a/server/db/pg/schema.ts +++ b/server/db/pg/schema.ts @@ -459,7 +459,7 @@ export const idpOidcConfig = pgTable("idpOidcConfig", { idpId: integer("idpId") .notNull() .references(() => idp.idpId, { onDelete: "cascade" }), - variant: varchar("variant").notNull().default("generic"), + variant: varchar("variant").notNull().default("oidc"), clientId: varchar("clientId").notNull(), clientSecret: varchar("clientSecret").notNull(), authUrl: varchar("authUrl").notNull(), diff --git a/server/db/sqlite/schema.ts b/server/db/sqlite/schema.ts index d29c36b2..ce32d8a6 100644 --- a/server/db/sqlite/schema.ts +++ b/server/db/sqlite/schema.ts @@ -595,7 +595,7 @@ export const idpOidcConfig = sqliteTable("idpOidcConfig", { idpOauthConfigId: integer("idpOauthConfigId").primaryKey({ autoIncrement: true }), - variant: text("variant").notNull().default("generic"), + variant: text("variant").notNull().default("oidc"), idpId: integer("idpId") .notNull() .references(() => idp.idpId, { onDelete: "cascade" }), From d55e4ef498aa6d95cc2f77129e7bf53fae820572 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 5 Sep 2025 11:23:43 -0700 Subject: [PATCH 013/166] Update build process --- docker-compose.yml | 1 - esbuild.mjs | 4 ++-- package.json | 2 +- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 09b150d7..469f9b4c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,7 +13,6 @@ services: environment: - NODE_ENV=development - ENVIRONMENT=dev - - DB_TYPE=pg volumes: # Mount source code for hot reload - ./src:/app/src diff --git a/esbuild.mjs b/esbuild.mjs index d76c0753..8086a77e 100644 --- a/esbuild.mjs +++ b/esbuild.mjs @@ -52,7 +52,7 @@ esbuild bundle: true, outfile: argv.out, format: "esm", - minify: true, + minify: false, banner: { js: banner, }, @@ -63,7 +63,7 @@ esbuild packagePath: getPackagePaths(), }), ], - sourcemap: "external", + sourcemap: "inline", target: "node22", }) .then(() => { diff --git a/package.json b/package.json index 2c1c3fca..95344201 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "db:clear-migrations": "rm -rf server/migrations", "build:sqlite": "mkdir -p dist && next build && node esbuild.mjs -e server/index.ts -o dist/server.mjs && node esbuild.mjs -e server/setup/migrationsSqlite.ts -o dist/migrations.mjs", "build:pg": "mkdir -p dist && next build && node esbuild.mjs -e server/index.ts -o dist/server.mjs && node esbuild.mjs -e server/setup/migrationsPg.ts -o dist/migrations.mjs", - "start": "DB_TYPE=sqlite NODE_OPTIONS=--enable-source-maps NODE_ENV=development ENVIRONMENT=prod sh -c 'node dist/migrations.mjs && node dist/server.mjs'", + "start": "NODE_OPTIONS=--enable-source-maps ENVIRONMENT=prod node dist/migrations.mjs && node dist/server.mjs", "email": "email dev --dir server/emails/templates --port 3005", "build:cli": "node esbuild.mjs -e cli/index.ts -o dist/cli.mjs" }, From 49d10eed33269c60b74d072fcfc4aa04f575583f Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 5 Sep 2025 11:25:13 -0700 Subject: [PATCH 014/166] Remove source map support --- package-lock.json | 4 +++- package.json | 1 - server/index.ts | 1 - 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2d8db128..bcced536 100644 --- a/package-lock.json +++ b/package-lock.json @@ -83,7 +83,6 @@ "react-icons": "^5.5.0", "rebuild": "0.1.2", "semver": "^7.7.2", - "source-map-support": "0.5.21", "swagger-ui-express": "^5.0.1", "tailwind-merge": "3.3.1", "tw-animate-css": "^1.3.7", @@ -6193,6 +6192,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, "license": "MIT" }, "node_modules/bytes": { @@ -15571,6 +15571,7 @@ "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -15589,6 +15590,7 @@ "version": "0.5.21", "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, "license": "MIT", "dependencies": { "buffer-from": "^1.0.0", diff --git a/package.json b/package.json index 95344201..83479b23 100644 --- a/package.json +++ b/package.json @@ -100,7 +100,6 @@ "react-icons": "^5.5.0", "rebuild": "0.1.2", "semver": "^7.7.2", - "source-map-support": "0.5.21", "swagger-ui-express": "^5.0.1", "tailwind-merge": "3.3.1", "tw-animate-css": "^1.3.7", diff --git a/server/index.ts b/server/index.ts index fb2ad396..5210ba5d 100644 --- a/server/index.ts +++ b/server/index.ts @@ -1,6 +1,5 @@ #! /usr/bin/env node import "./extendZod.ts"; -import 'source-map-support/register.js' import { runSetupFunctions } from "./setup"; import { createApiServer } from "./apiServer"; From 956db6ba2ea406e13245977f5d9fd1a6ba1e9a81 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 5 Sep 2025 11:45:42 -0700 Subject: [PATCH 015/166] Update start command one more time --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 83479b23..0d4b8732 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "db:clear-migrations": "rm -rf server/migrations", "build:sqlite": "mkdir -p dist && next build && node esbuild.mjs -e server/index.ts -o dist/server.mjs && node esbuild.mjs -e server/setup/migrationsSqlite.ts -o dist/migrations.mjs", "build:pg": "mkdir -p dist && next build && node esbuild.mjs -e server/index.ts -o dist/server.mjs && node esbuild.mjs -e server/setup/migrationsPg.ts -o dist/migrations.mjs", - "start": "NODE_OPTIONS=--enable-source-maps ENVIRONMENT=prod node dist/migrations.mjs && node dist/server.mjs", + "start": "ENVIRONMENT=prod node dist/migrations.mjs && ENVIRONMENT=prod node --enable-source-maps dist/server.mjs", "email": "email dev --dir server/emails/templates --port 3005", "build:cli": "node esbuild.mjs -e cli/index.ts -o dist/cli.mjs" }, From 35facd1d4ce750b8762a3dd5068f11098f0fbd15 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 5 Sep 2025 11:51:44 -0700 Subject: [PATCH 016/166] Add node env for react email issue back --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 0d4b8732..671034eb 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "db:clear-migrations": "rm -rf server/migrations", "build:sqlite": "mkdir -p dist && next build && node esbuild.mjs -e server/index.ts -o dist/server.mjs && node esbuild.mjs -e server/setup/migrationsSqlite.ts -o dist/migrations.mjs", "build:pg": "mkdir -p dist && next build && node esbuild.mjs -e server/index.ts -o dist/server.mjs && node esbuild.mjs -e server/setup/migrationsPg.ts -o dist/migrations.mjs", - "start": "ENVIRONMENT=prod node dist/migrations.mjs && ENVIRONMENT=prod node --enable-source-maps dist/server.mjs", + "start": "ENVIRONMENT=prod node dist/migrations.mjs && ENVIRONMENT=prod NODE_ENV=development node --enable-source-maps dist/server.mjs", "email": "email dev --dir server/emails/templates --port 3005", "build:cli": "node esbuild.mjs -e cli/index.ts -o dist/cli.mjs" }, From 5fd411a54ee19e160c238f01dfada882ef341ade Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Fri, 5 Sep 2025 16:14:01 -0700 Subject: [PATCH 017/166] add idp auto provision override on user --- messages/en-US.json | 10 +- public/idp/azure.png | Bin 0 -> 66912 bytes public/idp/google.png | Bin 0 -> 47408 bytes server/auth/actions.ts | 3 +- server/db/pg/schema.ts | 3 +- server/db/sqlite/schema.ts | 20 +- server/routers/external.ts | 8 + server/routers/idp/listIdps.ts | 16 +- server/routers/integration.ts | 18 +- server/routers/user/createOrgUser.ts | 18 +- server/routers/user/getOrgUser.ts | 10 +- server/routers/user/index.ts | 1 + server/routers/user/listUsers.ts | 5 +- server/routers/user/updateOrgUser.ts | 112 ++++ .../users/[userId]/access-controls/page.tsx | 97 ++- .../settings/access/users/create/page.tsx | 576 ++++++++++-------- .../[orgId]/settings/access/users/page.tsx | 1 + src/app/auth/login/page.tsx | 3 +- src/components/AdminIdpTable.tsx | 16 +- src/components/IdpTypeBadge.tsx | 62 ++ src/components/LoginForm.tsx | 49 +- src/components/PermissionsSelectBox.tsx | 4 +- src/components/UsersTable.tsx | 12 + 23 files changed, 732 insertions(+), 312 deletions(-) create mode 100644 public/idp/azure.png create mode 100644 public/idp/google.png create mode 100644 server/routers/user/updateOrgUser.ts create mode 100644 src/components/IdpTypeBadge.tsx diff --git a/messages/en-US.json b/messages/en-US.json index 90426d84..bd43a9c0 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -454,6 +454,8 @@ "accessRoleErrorAddDescription": "An error occurred while adding user to the role.", "userSaved": "User saved", "userSavedDescription": "The user has been updated.", + "autoProvisioned": "Auto Provisioned", + "autoProvisionedDescription": "Allow this user to be automatically managed by identity provider", "accessControlsDescription": "Manage what this user can access and do in the organization", "accessControlsSubmit": "Save Access Controls", "roles": "Roles", @@ -911,6 +913,8 @@ "idpConnectingToFinished": "Connected", "idpErrorConnectingTo": "There was a problem connecting to {name}. Please contact your administrator.", "idpErrorNotFound": "IdP not found", + "idpGoogleAlt": "Google", + "idpAzureAlt": "Azure", "inviteInvalid": "Invalid Invite", "inviteInvalidDescription": "The invite link is invalid.", "inviteErrorWrongUser": "Invite is not for this user", @@ -982,6 +986,8 @@ "licenseTierProfessionalRequired": "Professional Edition Required", "licenseTierProfessionalRequiredDescription": "This feature is only available in the Professional Edition.", "actionGetOrg": "Get Organization", + "updateOrgUser": "Update Org User", + "createOrgUser": "Create Org User", "actionUpdateOrg": "Update Organization", "actionUpdateUser": "Update User", "actionGetUser": "Get User", @@ -1496,5 +1502,7 @@ "convertButton": "Convert This Node to Managed Self-Hosted" }, "internationaldomaindetected": "International Domain Detected", - "willbestoredas": "Will be stored as:" + "willbestoredas": "Will be stored as:", + "idpGoogleDescription": "Google OAuth2/OIDC provider", + "idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider" } diff --git a/public/idp/azure.png b/public/idp/azure.png new file mode 100644 index 0000000000000000000000000000000000000000..d6ec5bafe4d58244fc898b1a443eb976e33d8b3c GIT binary patch literal 66912 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¨z48E}kxdpLNrpT?^qaoN(B3 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 literal 0 HcmV?d00001 diff --git a/public/idp/google.png b/public/idp/google.png new file mode 100644 index 0000000000000000000000000000000000000000..da0976870e323dae26b0b7077b6472354b4149d9 GIT binary patch literal 47408 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 roles.roleId), - isOwner: boolean("isOwner").notNull().default(false) + isOwner: boolean("isOwner").notNull().default(false), + autoProvisioned: boolean("autoProvisioned").default(false) }); export const emailVerificationCodes = pgTable("emailVerificationCodes", { diff --git a/server/db/sqlite/schema.ts b/server/db/sqlite/schema.ts index ce32d8a6..2cf24bc2 100644 --- a/server/db/sqlite/schema.ts +++ b/server/db/sqlite/schema.ts @@ -107,7 +107,7 @@ export const resources = sqliteTable("resources", { enableProxy: integer("enableProxy", { mode: "boolean" }).default(true), skipToIdpId: integer("skipToIdpId").references(() => idp.idpId, { onDelete: "cascade" - }), + }) }); export const targets = sqliteTable("targets", { @@ -143,8 +143,11 @@ export const exitNodes = sqliteTable("exitNodes", { type: text("type").default("gerbil") // gerbil, remoteExitNode }); -export const siteResources = sqliteTable("siteResources", { // this is for the clients - siteResourceId: integer("siteResourceId").primaryKey({ autoIncrement: true }), +export const siteResources = sqliteTable("siteResources", { + // this is for the clients + siteResourceId: integer("siteResourceId").primaryKey({ + autoIncrement: true + }), siteId: integer("siteId") .notNull() .references(() => sites.siteId, { onDelete: "cascade" }), @@ -156,7 +159,7 @@ export const siteResources = sqliteTable("siteResources", { // this is for the c proxyPort: integer("proxyPort").notNull(), destinationPort: integer("destinationPort").notNull(), destinationIp: text("destinationIp").notNull(), - enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), + enabled: integer("enabled", { mode: "boolean" }).notNull().default(true) }); export const users = sqliteTable("user", { @@ -260,7 +263,9 @@ export const clientSites = sqliteTable("clientSites", { siteId: integer("siteId") .notNull() .references(() => sites.siteId, { onDelete: "cascade" }), - isRelayed: integer("isRelayed", { mode: "boolean" }).notNull().default(false), + isRelayed: integer("isRelayed", { mode: "boolean" }) + .notNull() + .default(false), endpoint: text("endpoint") }); @@ -318,7 +323,10 @@ export const userOrgs = sqliteTable("userOrgs", { roleId: integer("roleId") .notNull() .references(() => roles.roleId), - isOwner: integer("isOwner", { mode: "boolean" }).notNull().default(false) + isOwner: integer("isOwner", { mode: "boolean" }).notNull().default(false), + autoProvisioned: integer("autoProvisioned", { + mode: "boolean" + }).default(false) }); export const emailVerificationCodes = sqliteTable("emailVerificationCodes", { diff --git a/server/routers/external.ts b/server/routers/external.ts index ce44bd8a..6d00eb3c 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -582,6 +582,14 @@ authenticated.put( user.createOrgUser ); +authenticated.post( + "/org/:orgId/user/:userId", + verifyOrgAccess, + verifyUserAccess, + verifyUserHasAction(ActionsEnum.updateOrgUser), + user.updateOrgUser +); + authenticated.get("/org/:orgId/user/:userId", verifyOrgAccess, user.getOrgUser); authenticated.post( diff --git a/server/routers/idp/listIdps.ts b/server/routers/idp/listIdps.ts index 2a0e5809..c9e2c271 100644 --- a/server/routers/idp/listIdps.ts +++ b/server/routers/idp/listIdps.ts @@ -1,11 +1,11 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db } from "@server/db"; +import { db, idpOidcConfig } from "@server/db"; import { domains, idp, orgDomains, users, idpOrg } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; -import { sql } from "drizzle-orm"; +import { eq, sql } from "drizzle-orm"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; @@ -33,10 +33,13 @@ async function query(limit: number, offset: number) { idpId: idp.idpId, name: idp.name, type: idp.type, - orgCount: sql`count(${idpOrg.orgId})` + variant: idpOidcConfig.variant, + orgCount: sql`count(${idpOrg.orgId})`, + autoProvision: idp.autoProvision }) .from(idp) .leftJoin(idpOrg, sql`${idp.idpId} = ${idpOrg.idpId}`) + .leftJoin(idpOidcConfig, eq(idp.idpId, idpOidcConfig.idpId)) .groupBy(idp.idpId) .limit(limit) .offset(offset); @@ -44,12 +47,7 @@ async function query(limit: number, offset: number) { } export type ListIdpsResponse = { - idps: Array<{ - idpId: number; - name: string; - type: string; - orgCount: number; - }>; + idps: Awaited>; pagination: { total: number; limit: number; diff --git a/server/routers/integration.ts b/server/routers/integration.ts index 79453732..69bdbb42 100644 --- a/server/routers/integration.ts +++ b/server/routers/integration.ts @@ -24,7 +24,8 @@ import { verifyApiKeyIsRoot, verifyApiKeyClientAccess, verifyClientsEnabled, - verifyApiKeySiteResourceAccess + verifyApiKeySiteResourceAccess, + verifyOrgAccess } from "@server/middlewares"; import HttpCode from "@server/types/HttpCode"; import { Router } from "express"; @@ -469,6 +470,21 @@ authenticated.get( user.listUsers ); +authenticated.put( + "/org/:orgId/user", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.createOrgUser), + user.createOrgUser +); + +authenticated.post( + "/org/:orgId/user/:userId", + verifyApiKeyOrgAccess, + verifyApiKeyUserAccess, + verifyApiKeyHasAction(ActionsEnum.updateOrgUser), + user.updateOrgUser +); + authenticated.delete( "/org/:orgId/user/:userId", verifyApiKeyOrgAccess, diff --git a/server/routers/user/createOrgUser.ts b/server/routers/user/createOrgUser.ts index 5193e8fa..5b11c923 100644 --- a/server/routers/user/createOrgUser.ts +++ b/server/routers/user/createOrgUser.ts @@ -84,7 +84,14 @@ export async function createOrgUser( } const { orgId } = parsedParams.data; - const { username, email, name, type, idpId, roleId } = parsedBody.data; + const { + username, + email, + name, + type, + idpId, + roleId + } = parsedBody.data; const [role] = await db .select() @@ -173,7 +180,8 @@ export async function createOrgUser( .values({ orgId, userId: existingUser.userId, - roleId: role.roleId + roleId: role.roleId, + autoProvisioned: false }) .returning(); } else { @@ -189,7 +197,7 @@ export async function createOrgUser( type: "oidc", idpId, dateCreated: new Date().toISOString(), - emailVerified: true + emailVerified: true, }) .returning(); @@ -198,7 +206,8 @@ export async function createOrgUser( .values({ orgId, userId: newUser.userId, - roleId: role.roleId + roleId: role.roleId, + autoProvisioned: false }) .returning(); } @@ -209,7 +218,6 @@ export async function createOrgUser( .from(userOrgs) .where(eq(userOrgs.orgId, orgId)); }); - } else { return next( createHttpError(HttpCode.BAD_REQUEST, "User type is required") diff --git a/server/routers/user/getOrgUser.ts b/server/routers/user/getOrgUser.ts index 38cd70a6..bdec2d12 100644 --- a/server/routers/user/getOrgUser.ts +++ b/server/routers/user/getOrgUser.ts @@ -1,6 +1,6 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db } from "@server/db"; +import { db, idp, idpOidcConfig } from "@server/db"; import { roles, userOrgs, users } from "@server/db"; import { and, eq, sql } from "drizzle-orm"; import response from "@server/lib/response"; @@ -25,10 +25,18 @@ async function queryUser(orgId: string, userId: string) { isOwner: userOrgs.isOwner, isAdmin: roles.isAdmin, twoFactorEnabled: users.twoFactorEnabled, + autoProvisioned: userOrgs.autoProvisioned, + idpId: users.idpId, + idpName: idp.name, + idpType: idp.type, + idpVariant: idpOidcConfig.variant, + idpAutoProvision: idp.autoProvision }) .from(userOrgs) .leftJoin(roles, eq(userOrgs.roleId, roles.roleId)) .leftJoin(users, eq(userOrgs.userId, users.userId)) + .leftJoin(idp, eq(users.idpId, idp.idpId)) + .leftJoin(idpOidcConfig, eq(idp.idpId, idpOidcConfig.idpId)) .where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))) .limit(1); if (typeof user.roles === "string") { diff --git a/server/routers/user/index.ts b/server/routers/user/index.ts index 4c5dbf86..a54ce681 100644 --- a/server/routers/user/index.ts +++ b/server/routers/user/index.ts @@ -14,3 +14,4 @@ export * from "./removeInvitation"; export * from "./createOrgUser"; export * from "./adminUpdateUser2FA"; export * from "./adminGetUser"; +export * from "./updateOrgUser"; diff --git a/server/routers/user/listUsers.ts b/server/routers/user/listUsers.ts index edcc5dce..0535a79d 100644 --- a/server/routers/user/listUsers.ts +++ b/server/routers/user/listUsers.ts @@ -1,6 +1,6 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db } from "@server/db"; +import { db, idpOidcConfig } from "@server/db"; import { idp, roles, userOrgs, users } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; @@ -61,12 +61,15 @@ async function queryUsers(orgId: string, limit: number, offset: number) { isOwner: userOrgs.isOwner, idpName: idp.name, idpId: users.idpId, + idpType: idp.type, + idpVariant: idpOidcConfig.variant, twoFactorEnabled: users.twoFactorEnabled, }) .from(users) .leftJoin(userOrgs, eq(users.userId, userOrgs.userId)) .leftJoin(roles, eq(userOrgs.roleId, roles.roleId)) .leftJoin(idp, eq(users.idpId, idp.idpId)) + .leftJoin(idpOidcConfig, eq(idpOidcConfig.idpId, idp.idpId)) .where(eq(userOrgs.orgId, orgId)) .groupBy(users.userId) .limit(limit) diff --git a/server/routers/user/updateOrgUser.ts b/server/routers/user/updateOrgUser.ts new file mode 100644 index 00000000..fb00b59f --- /dev/null +++ b/server/routers/user/updateOrgUser.ts @@ -0,0 +1,112 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, userOrgs } from "@server/db"; +import { and, 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 { OpenAPITags, registry } from "@server/openApi"; + +const paramsSchema = z + .object({ + userId: z.string(), + orgId: z.string() + }) + .strict(); + +const bodySchema = z + .object({ + autoProvisioned: z.boolean().optional() + }) + .strict() + .refine((data) => Object.keys(data).length > 0, { + message: "At least one field must be provided for update" + }); + +registry.registerPath({ + method: "post", + path: "/org/{orgId}/user/{userId}", + description: "Update a user in an org.", + tags: [OpenAPITags.Org, OpenAPITags.User], + request: { + params: paramsSchema, + body: { + content: { + "application/json": { + schema: bodySchema + } + } + } + }, + responses: {} +}); + +export async function updateOrgUser( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const parsedBody = bodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { userId, orgId } = parsedParams.data; + + const [existingUser] = await db + .select() + .from(userOrgs) + .where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))) + .limit(1); + + if (!existingUser) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "User not found in this organization" + ) + ); + } + + const updateData = parsedBody.data; + + const [updatedUser] = await db + .update(userOrgs) + .set({ + ...updateData + }) + .where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))) + .returning(); + + return response(res, { + data: updatedUser, + success: true, + error: false, + message: "Org user updated successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx b/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx index c56c45cc..30c55053 100644 --- a/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx +++ b/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx @@ -8,6 +8,7 @@ import { FormLabel, FormMessage } from "@app/components/ui/form"; +import { Checkbox } from "@app/components/ui/checkbox"; import { toast } from "@app/hooks/useToast"; import { zodResolver } from "@hookform/resolvers/zod"; import { SetUserRolesResponse } from "@server/routers/user"; @@ -34,6 +35,8 @@ import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { Tag, TagInput } from "@app/components/tags/tag-input"; import { useTranslations } from "next-intl"; +import IdpTypeBadge from "@app/components/IdpTypeBadge"; +import { UserType } from "@server/types/UserTypes"; export default function AccessControlsPage() { const { orgUser: user } = userOrgUserContext(); @@ -61,14 +64,16 @@ export default function AccessControlsPage() { text: z.string() }) ) - .min(1, { message: t('accessRoleSelectPlease') }) + .min(1, { message: t('accessRoleSelectPlease') }), + autoProvisioned: z.boolean() }); const form = useForm>({ resolver: zodResolver(formSchema), defaultValues: { username: user.username!, - roles: [] + roles: [], + autoProvisioned: user.autoProvisioned || false } }); @@ -107,31 +112,38 @@ export default function AccessControlsPage() { text: i.name })) ); + form.setValue("autoProvisioned", user.autoProvisioned || false); }, []); async function onSubmit(values: z.infer) { setLoading(true); - const res = await api - .post< - AxiosResponse - >(`/org/${user.orgId}/user/${user.userId}/roles`, { roleIds: values.roles.map((r) => parseInt(r.id)) }) - .catch((e) => { - toast({ - variant: "destructive", - title: t('accessRoleErrorAdd'), - description: formatAxiosError( - e, - t('accessRoleErrorAddDescription') - ) - }); - }); + try { + // Execute both API calls simultaneously + const [roleRes, userRes] = await Promise.all([ + api.post>(`/org/${user.orgId}/user/${user.userId}/roles`, { + roleIds: values.roles.map((r) => parseInt(r.id)) } + ), + api.post(`/org/${orgId}/user/${user.userId}`, { + autoProvisioned: values.autoProvisioned + }) + ]); - if (res && res.status === 200) { + if (roleRes.status === 200 && userRes.status === 200) { + toast({ + variant: "default", + title: t('userSaved'), + description: t('userSavedDescription') + }); + } + } catch (e) { toast({ - variant: "default", - title: t('userSaved'), - description: t('userSavedDescription') + variant: "destructive", + title: t('accessRoleErrorAdd'), + description: formatAxiosError( + e, + t('accessRoleErrorAddDescription') + ) }); } @@ -156,12 +168,26 @@ export default function AccessControlsPage() { className="space-y-4" id="access-controls-form" > + {/* IDP Type Display */} + {user.type !== UserType.Internal && user.idpType && ( +
+ + {t("idp")}: + + +
+ )} + ( - Roles + {t('roles')} )} /> + + {user.idpAutoProvision && ( + ( + + + + +
+ + {t('autoProvisioned')} + +

+ {t('autoProvisionedDescription')} +

+
+
+ )} + /> + )} diff --git a/src/app/[orgId]/settings/access/users/create/page.tsx b/src/app/[orgId]/settings/access/users/create/page.tsx index 6ae00b61..bd50a883 100644 --- a/src/app/[orgId]/settings/access/users/create/page.tsx +++ b/src/app/[orgId]/settings/access/users/create/page.tsx @@ -46,6 +46,7 @@ import { Checkbox } from "@app/components/ui/checkbox"; import { ListIdpsResponse } from "@server/routers/idp"; import { useTranslations } from "next-intl"; import { build } from "@server/build"; +import Image from "next/image"; type UserType = "internal" | "oidc"; @@ -53,6 +54,17 @@ interface IdpOption { idpId: number; name: string; type: string; + variant: string | null; +} + +interface UserOption { + id: string; + title: string; + description: string; + disabled: boolean; + icon?: React.ReactNode; + idpId?: number; + variant?: string | null; } export default function Page() { @@ -62,14 +74,14 @@ export default function Page() { const api = createApiClient({ env }); const t = useTranslations(); - const [userType, setUserType] = useState("internal"); + const [selectedOption, setSelectedOption] = useState("internal"); const [inviteLink, setInviteLink] = useState(null); const [loading, setLoading] = useState(false); const [expiresInDays, setExpiresInDays] = useState(1); const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]); const [idps, setIdps] = useState([]); const [sendEmail, setSendEmail] = useState(env.email.emailEnabled); - const [selectedIdp, setSelectedIdp] = useState(null); + const [userOptions, setUserOptions] = useState([]); const [dataLoaded, setDataLoaded] = useState(false); const internalFormSchema = z.object({ @@ -80,7 +92,13 @@ export default function Page() { roleId: z.string().min(1, { message: t("accessRoleSelectPlease") }) }); - const externalFormSchema = z.object({ + const googleAzureFormSchema = z.object({ + email: z.string().email({ message: t("emailInvalid") }), + name: z.string().optional(), + roleId: z.string().min(1, { message: t("accessRoleSelectPlease") }) + }); + + const genericOidcFormSchema = z.object({ username: z.string().min(1, { message: t("usernameRequired") }), email: z .string() @@ -96,11 +114,44 @@ export default function Page() { switch (type.toLowerCase()) { case "oidc": return t("idpGenericOidc"); + case "google": + return t("idpGoogleDescription"); + case "azure": + return t("idpAzureDescription"); default: return type; } }; + const getIdpIcon = (variant: string | null) => { + if (!variant) return null; + + switch (variant.toLowerCase()) { + case "google": + return ( + {t("idpGoogleAlt")} + ); + case "azure": + return ( + {t("idpAzureAlt")} + ); + default: + return null; + } + }; + const validFor = [ { hours: 24, name: t("day", { count: 1 }) }, { hours: 48, name: t("day", { count: 2 }) }, @@ -120,8 +171,17 @@ export default function Page() { } }); - const externalForm = useForm>({ - resolver: zodResolver(externalFormSchema), + const googleAzureForm = useForm>({ + resolver: zodResolver(googleAzureFormSchema), + defaultValues: { + email: "", + name: "", + roleId: "" + } + }); + + const genericOidcForm = useForm>({ + resolver: zodResolver(genericOidcFormSchema), defaultValues: { username: "", email: "", @@ -132,33 +192,19 @@ export default function Page() { }); useEffect(() => { - if (userType === "internal") { + if (selectedOption === "internal") { setSendEmail(env.email.emailEnabled); internalForm.reset(); setInviteLink(null); setExpiresInDays(1); - } else if (userType === "oidc") { - externalForm.reset(); + } else if (selectedOption && selectedOption !== "internal") { + googleAzureForm.reset(); + genericOidcForm.reset(); } - }, [userType, env.email.emailEnabled, internalForm, externalForm]); - - const [userTypes, setUserTypes] = useState[]>([ - { - id: "internal", - title: t("userTypeInternal"), - description: t("userTypeInternalDescription"), - disabled: false - }, - { - id: "oidc", - title: t("userTypeExternal"), - description: t("userTypeExternalDescription"), - disabled: true - } - ]); + }, [selectedOption, env.email.emailEnabled, internalForm, googleAzureForm, genericOidcForm]); useEffect(() => { - if (!userType) { + if (!selectedOption) { return; } @@ -199,20 +245,6 @@ export default function Page() { if (res?.status === 200) { setIdps(res.data.data.idps); - - if (res.data.data.idps.length) { - setUserTypes((prev) => - prev.map((type) => { - if (type.id === "oidc") { - return { - ...type, - disabled: false - }; - } - return type; - }) - ); - } } } @@ -226,6 +258,33 @@ export default function Page() { fetchInitialData(); }, []); + // Build user options when IDPs are loaded + useEffect(() => { + const options: UserOption[] = [ + { + id: "internal", + title: t("userTypeInternal"), + description: t("userTypeInternalDescription"), + disabled: false + } + ]; + + // Add IDP options + idps.forEach((idp) => { + options.push({ + id: `idp-${idp.idpId}`, + title: idp.name, + description: formatIdpType(idp.variant || idp.type), + disabled: false, + icon: getIdpIcon(idp.variant), + idpId: idp.idpId, + variant: idp.variant + }); + }); + + setUserOptions(options); + }, [idps, t]); + async function onSubmitInternal( values: z.infer ) { @@ -274,9 +333,52 @@ export default function Page() { setLoading(false); } - async function onSubmitExternal( - values: z.infer + async function onSubmitGoogleAzure( + values: z.infer ) { + const selectedUserOption = userOptions.find(opt => opt.id === selectedOption); + if (!selectedUserOption?.idpId) return; + + setLoading(true); + + const res = await api + .put(`/org/${orgId}/user`, { + username: values.email, // Use email as username for Google/Azure + email: values.email, + name: values.name, + type: "oidc", + idpId: selectedUserOption.idpId, + roleId: parseInt(values.roleId) + }) + .catch((e) => { + toast({ + variant: "destructive", + title: t("userErrorCreate"), + description: formatAxiosError( + e, + t("userErrorCreateDescription") + ) + }); + }); + + if (res && res.status === 201) { + toast({ + variant: "default", + title: t("userCreated"), + description: t("userCreatedDescription") + }); + router.push(`/${orgId}/settings/access/users`); + } + + setLoading(false); + } + + async function onSubmitGenericOidc( + values: z.infer + ) { + const selectedUserOption = userOptions.find(opt => opt.id === selectedOption); + if (!selectedUserOption?.idpId) return; + setLoading(true); const res = await api @@ -285,7 +387,7 @@ export default function Page() { email: values.email, name: values.name, type: "oidc", - idpId: parseInt(values.idpId), + idpId: selectedUserOption.idpId, roleId: parseInt(values.roleId) }) .catch((e) => { @@ -330,7 +432,7 @@ export default function Page() {
- {!inviteLink && build !== "saas" ? ( + {!inviteLink && build !== "saas" && dataLoaded ? ( @@ -342,15 +444,15 @@ export default function Page() { { - setUserType(value as UserType); + setSelectedOption(value); if (value === "internal") { internalForm.reset(); - } else if (value === "oidc") { - externalForm.reset(); - setSelectedIdp(null); + } else { + googleAzureForm.reset(); + genericOidcForm.reset(); } }} cols={2} @@ -359,7 +461,7 @@ export default function Page() { ) : null} - {userType === "internal" && dataLoaded && ( + {selectedOption === "internal" && dataLoaded && ( <> {!inviteLink ? ( @@ -564,71 +666,7 @@ export default function Page() { )} - {userType !== "internal" && dataLoaded && ( - <> - - - - {t("idpTitle")} - - - {t("idpSelect")} - - - - {idps.length === 0 ? ( -

- {t("idpNotConfigured")} -

- ) : ( -
- ( - - ({ - id: idp.idpId.toString(), - title: idp.name, - description: - formatIdpType( - idp.type - ) - }) - )} - defaultValue={ - field.value - } - onChange={( - value - ) => { - field.onChange( - value - ); - const idp = - idps.find( - (idp) => - idp.idpId.toString() === - value - ); - setSelectedIdp( - idp || null - ); - }} - cols={2} - /> - - - )} - /> - - )} -
-
- - {idps.length > 0 && ( + {selectedOption && selectedOption !== "internal" && dataLoaded && ( @@ -640,144 +678,206 @@ export default function Page() { -
- - ( - - - {t( - "username" - )} - - - - -

- {t( - "usernameUniq" - )} -

- -
+ {/* Google/Azure Form */} + {(() => { + const selectedUserOption = userOptions.find(opt => opt.id === selectedOption); + return selectedUserOption?.variant === "google" || selectedUserOption?.variant === "azure"; + })() && ( + + - - ( - - - {t( - "emailOptional" - )} - - - - - - - )} - /> - - ( - - - {t( - "nameOptional" - )} - - - - - - - )} - /> - - ( - - - {t("role")} - - - - {roles.map( - ( - role - ) => ( + + + )} + /> + + ( + + + {t("nameOptional")} + + + + + + + )} + /> + + ( + + + {t("role")} + + - - + ))} + + + + + )} + /> + + + )} + + {/* Generic OIDC Form */} + {(() => { + const selectedUserOption = userOptions.find(opt => opt.id === selectedOption); + return selectedUserOption?.variant !== "google" && selectedUserOption?.variant !== "azure"; + })() && ( +
+ -
- + className="space-y-4" + id="create-user-form" + > + ( + + + {t("username")} + + + + +

+ {t("usernameUniq")} +

+ +
+ )} + /> + + ( + + + {t("emailOptional")} + + + + + + + )} + /> + + ( + + + {t("nameOptional")} + + + + + + + )} + /> + + ( + + + {t("role")} + + + + + )} + /> + + + )}
- )} - )}
- {userType && dataLoaded && ( + {selectedOption && dataLoaded && (
- {idps.map((idp) => ( - - ))} + {idps.map((idp) => { + const effectiveType = idp.variant || idp.name.toLowerCase(); + + return ( + + ); + })} )} diff --git a/src/components/PermissionsSelectBox.tsx b/src/components/PermissionsSelectBox.tsx index d8f9b59f..3334cca5 100644 --- a/src/components/PermissionsSelectBox.tsx +++ b/src/components/PermissionsSelectBox.tsx @@ -27,7 +27,9 @@ function getActionsCategories(root: boolean) { [t('actionListInvitations')]: "listInvitations", [t('actionRemoveUser')]: "removeUser", [t('actionListUsers')]: "listUsers", - [t('actionListOrgDomains')]: "listOrgDomains" + [t('actionListOrgDomains')]: "listOrgDomains", + [t('updateOrgUser')]: "updateOrgUser", + [t('createOrgUser')]: "createOrgUser" }, Site: { diff --git a/src/components/UsersTable.tsx b/src/components/UsersTable.tsx index a5526303..57fe5c91 100644 --- a/src/components/UsersTable.tsx +++ b/src/components/UsersTable.tsx @@ -21,6 +21,7 @@ import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { useUserContext } from "@app/hooks/useUserContext"; import { useTranslations } from "next-intl"; +import IdpTypeBadge from "./IdpTypeBadge"; export type UserRow = { id: string; @@ -31,6 +32,7 @@ export type UserRow = { idpId: number | null; idpName: string; type: string; + idpVariant: string | null; status: string; role: string; isOwner: boolean; @@ -81,6 +83,16 @@ export default function UsersTable({ users: u }: UsersTableProps) { ); + }, + cell: ({ row }) => { + const userRow = row.original; + return ( + + ); } }, { From 5efd1d5405862c6e4c03f1c39d9428cf49a8d23e Mon Sep 17 00:00:00 2001 From: Owen Date: Sat, 6 Sep 2025 16:54:06 -0700 Subject: [PATCH 018/166] Add region --- server/db/pg/schema.ts | 3 ++- server/db/sqlite/schema.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/server/db/pg/schema.ts b/server/db/pg/schema.ts index fc1f6ec3..1f571ab4 100644 --- a/server/db/pg/schema.ts +++ b/server/db/pg/schema.ts @@ -128,7 +128,8 @@ export const exitNodes = pgTable("exitNodes", { maxConnections: integer("maxConnections"), online: boolean("online").notNull().default(false), lastPing: integer("lastPing"), - type: text("type").default("gerbil") // gerbil, remoteExitNode + type: text("type").default("gerbil"), // gerbil, remoteExitNode + region: varchar("region") }); export const siteResources = pgTable("siteResources", { // this is for the clients diff --git a/server/db/sqlite/schema.ts b/server/db/sqlite/schema.ts index 2cf24bc2..90b09734 100644 --- a/server/db/sqlite/schema.ts +++ b/server/db/sqlite/schema.ts @@ -140,7 +140,8 @@ export const exitNodes = sqliteTable("exitNodes", { maxConnections: integer("maxConnections"), online: integer("online", { mode: "boolean" }).notNull().default(false), lastPing: integer("lastPing"), - type: text("type").default("gerbil") // gerbil, remoteExitNode + type: text("type").default("gerbil"), // gerbil, remoteExitNode + region: text("region") }); export const siteResources = sqliteTable("siteResources", { From 26cc0f81843ea2a5aa2acee412c01f07842b0a29 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Sat, 6 Sep 2025 17:26:44 -0700 Subject: [PATCH 019/166] fix listIdp query error --- server/routers/idp/listIdps.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/routers/idp/listIdps.ts b/server/routers/idp/listIdps.ts index c9e2c271..150b9f88 100644 --- a/server/routers/idp/listIdps.ts +++ b/server/routers/idp/listIdps.ts @@ -39,8 +39,8 @@ async function query(limit: number, offset: number) { }) .from(idp) .leftJoin(idpOrg, sql`${idp.idpId} = ${idpOrg.idpId}`) - .leftJoin(idpOidcConfig, eq(idp.idpId, idpOidcConfig.idpId)) - .groupBy(idp.idpId) + .leftJoin(idpOidcConfig, eq(idpOidcConfig.idpId, idp.idpId)) + .groupBy(idp.idpId, idpOidcConfig.variant) .limit(limit) .offset(offset); return res; From 685be473bc4fff1a7f8511eb45282a1c17b79111 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Sat, 6 Sep 2025 21:38:17 -0700 Subject: [PATCH 020/166] remove extra components --- .../users/[userId]/access-controls/page.tsx | 95 +-- .../share-links/CreateShareLinkForm.tsx | 549 ------------------ .../settings/share-links/ShareLinksTable.tsx | 298 ---------- src/app/[orgId]/settings/share-links/page.tsx | 1 - 4 files changed, 52 insertions(+), 891 deletions(-) delete mode 100644 src/app/[orgId]/settings/share-links/CreateShareLinkForm.tsx delete mode 100644 src/app/[orgId]/settings/share-links/ShareLinksTable.tsx diff --git a/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx b/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx index 30c55053..fdce89eb 100644 --- a/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx +++ b/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx @@ -85,10 +85,10 @@ export default function AccessControlsPage() { console.error(e); toast({ variant: "destructive", - title: t('accessRoleErrorFetch'), + title: t("accessRoleErrorFetch"), description: formatAxiosError( e, - t('accessRoleErrorFetchDescription') + t("accessRoleErrorFetchDescription") ) }); }); @@ -132,17 +132,17 @@ export default function AccessControlsPage() { if (roleRes.status === 200 && userRes.status === 200) { toast({ variant: "default", - title: t('userSaved'), - description: t('userSavedDescription') + title: t("userSaved"), + description: t("userSavedDescription") }); } } catch (e) { toast({ variant: "destructive", - title: t('accessRoleErrorAdd'), + title: t("accessRoleErrorAdd"), description: formatAxiosError( e, - t('accessRoleErrorAddDescription') + t("accessRoleErrorAddDescription") ) }); } @@ -154,9 +154,11 @@ export default function AccessControlsPage() { - {t('accessControls')} + + {t("accessControls")} + - {t('accessControlsDescription')} + {t("accessControlsDescription")} @@ -169,18 +171,21 @@ export default function AccessControlsPage() { id="access-controls-form" > {/* IDP Type Display */} - {user.type !== UserType.Internal && user.idpType && ( -
- - {t("idp")}: - - -
- )} + {user.type !== UserType.Internal && + user.idpType && ( +
+ + {t("idp")}: + + +
+ )} {user.idpAutoProvision && ( - ( - - - - -
- - {t('autoProvisioned')} - -

- {t('autoProvisionedDescription')} -

-
-
- )} - /> + ( + + + + +
+ + {t("autoProvisioned")} + +

+ {t( + "autoProvisionedDescription" + )} +

+
+
+ )} + /> )} @@ -267,7 +276,7 @@ export default function AccessControlsPage() { disabled={loading} form="access-controls-form" > - {t('accessControlsSubmit')} + {t("accessControlsSubmit")}
diff --git a/src/app/[orgId]/settings/share-links/CreateShareLinkForm.tsx b/src/app/[orgId]/settings/share-links/CreateShareLinkForm.tsx deleted file mode 100644 index e3ba3f17..00000000 --- a/src/app/[orgId]/settings/share-links/CreateShareLinkForm.tsx +++ /dev/null @@ -1,549 +0,0 @@ -"use client"; - -import { Button } from "@app/components/ui/button"; -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage -} from "@app/components/ui/form"; -import { Input } from "@app/components/ui/input"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue -} from "@app/components/ui/select"; -import { toast } from "@app/hooks/useToast"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { AxiosResponse } from "axios"; -import { useEffect, useState } from "react"; -import { useForm } from "react-hook-form"; -import { z } from "zod"; -import CopyTextBox from "@app/components/CopyTextBox"; -import { - Credenza, - CredenzaBody, - CredenzaClose, - CredenzaContent, - CredenzaDescription, - CredenzaFooter, - CredenzaHeader, - CredenzaTitle -} from "@app/components/Credenza"; -import { useOrgContext } from "@app/hooks/useOrgContext"; -import { formatAxiosError } from "@app/lib/api"; -import { cn } from "@app/lib/cn"; -import { createApiClient } from "@app/lib/api"; -import { useEnvContext } from "@app/hooks/useEnvContext"; -import { ListResourcesResponse } from "@server/routers/resource"; -import { - Popover, - PopoverContent, - PopoverTrigger -} from "@app/components/ui/popover"; -import { CaretSortIcon } from "@radix-ui/react-icons"; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList -} from "@app/components/ui/command"; -import { CheckIcon, ChevronsUpDown } from "lucide-react"; -import { Checkbox } from "@app/components/ui/checkbox"; -import { GenerateAccessTokenResponse } from "@server/routers/accessToken"; -import { constructShareLink } from "@app/lib/shareLinks"; -import { ShareLinkRow } from "@app/components/ShareLinksTable"; -import { QRCodeCanvas, QRCodeSVG } from "qrcode.react"; -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger -} from "@app/components/ui/collapsible"; -import AccessTokenSection from "@app/components/AccessTokenUsage"; -import { useTranslations } from "next-intl"; -import { toUnicode } from 'punycode'; - -type FormProps = { - open: boolean; - setOpen: (open: boolean) => void; - onCreated?: (result: ShareLinkRow) => void; -}; - -export default function CreateShareLinkForm({ - open, - setOpen, - onCreated -}: FormProps) { - const { org } = useOrgContext(); - - const { env } = useEnvContext(); - const api = createApiClient({ env }); - - const [link, setLink] = useState(null); - const [accessTokenId, setAccessTokenId] = useState(null); - const [accessToken, setAccessToken] = useState(null); - const [loading, setLoading] = useState(false); - const [neverExpire, setNeverExpire] = useState(false); - - const [isOpen, setIsOpen] = useState(false); - const t = useTranslations(); - - const [resources, setResources] = useState< - { - resourceId: number; - name: string; - resourceUrl: string; - }[] - >([]); - - const formSchema = z.object({ - resourceId: z.number({ message: t('shareErrorSelectResource') }), - resourceName: z.string(), - resourceUrl: z.string(), - timeUnit: z.string(), - timeValue: z.coerce.number().int().positive().min(1), - title: z.string().optional() - }); - - const timeUnits = [ - { unit: "minutes", name: t('minutes') }, - { unit: "hours", name: t('hours') }, - { unit: "days", name: t('days') }, - { unit: "weeks", name: t('weeks') }, - { unit: "months", name: t('months') }, - { unit: "years", name: t('years') } - ]; - - const form = useForm>({ - resolver: zodResolver(formSchema), - defaultValues: { - timeUnit: "days", - timeValue: 30, - title: "" - } - }); - - useEffect(() => { - if (!open) { - return; - } - - async function fetchResources() { - const res = await api - .get< - AxiosResponse - >(`/org/${org?.org.orgId}/resources`) - .catch((e) => { - console.error(e); - toast({ - variant: "destructive", - title: t('shareErrorFetchResource'), - description: formatAxiosError( - e, - t('shareErrorFetchResourceDescription') - ) - }); - }); - - if (res?.status === 200) { - setResources( - res.data.data.resources - .filter((r) => { - return r.http; - }) - .map((r) => ({ - resourceId: r.resourceId, - name: r.name, - resourceUrl: `${r.ssl ? "https://" : "http://"}${toUnicode(r.fullDomain || "")}/` - })) - ); - } - } - - fetchResources(); - }, [open]); - - async function onSubmit(values: z.infer) { - setLoading(true); - - // convert time to seconds - let timeInSeconds = values.timeValue; - switch (values.timeUnit) { - case "minutes": - timeInSeconds *= 60; - break; - case "hours": - timeInSeconds *= 60 * 60; - break; - case "days": - timeInSeconds *= 60 * 60 * 24; - break; - case "weeks": - timeInSeconds *= 60 * 60 * 24 * 7; - break; - case "months": - timeInSeconds *= 60 * 60 * 24 * 30; - break; - case "years": - timeInSeconds *= 60 * 60 * 24 * 365; - break; - } - - const res = await api - .post>( - `/resource/${values.resourceId}/access-token`, - { - validForSeconds: neverExpire ? undefined : timeInSeconds, - title: - values.title || - t('shareLink', {resource: (values.resourceName || "Resource" + values.resourceId)}) - } - ) - .catch((e) => { - console.error(e); - toast({ - variant: "destructive", - title: t('shareErrorCreate'), - description: formatAxiosError( - e, - t('shareErrorCreateDescription') - ) - }); - }); - - if (res && res.data.data.accessTokenId) { - const token = res.data.data; - const link = constructShareLink(token.accessToken); - setLink(link); - - setAccessToken(token.accessToken); - setAccessTokenId(token.accessTokenId); - - const resource = resources.find( - (r) => r.resourceId === values.resourceId - ); - - onCreated?.({ - accessTokenId: token.accessTokenId, - resourceId: token.resourceId, - resourceName: values.resourceName, - title: token.title, - createdAt: token.createdAt, - expiresAt: token.expiresAt - }); - } - - setLoading(false); - } - - function getSelectedResourceName(id: number) { - const resource = resources.find((r) => r.resourceId === id); - return `${resource?.name}`; - } - - return ( - <> - { - setOpen(val); - setLink(null); - setLoading(false); - form.reset(); - }} - > - - - {t('shareCreate')} - - {t('shareCreateDescription')} - - - -
- {!link && ( -
- - ( - - - {t('resource')} - - - - - - - - - - - - - {t('resourcesNotFound')} - - - {resources.map( - ( - r - ) => ( - { - form.setValue( - "resourceId", - r.resourceId - ); - form.setValue( - "resourceName", - r.name - ); - form.setValue( - "resourceUrl", - r.resourceUrl - ); - }} - > - - {`${r.name}`} - - ) - )} - - - - - - - - )} - /> - - ( - - - {t('shareTitleOptional')} - - - - - - - )} - /> - -
-
- {t('expireIn')} -
- ( - - - - - )} - /> - - ( - - - - - - - )} - /> -
-
- -
- - setNeverExpire( - val as boolean - ) - } - /> - -
- -

- {t('shareExpireDescription')} -

-
- - - )} - {link && ( -
-

- {t('shareSeeOnce')} -

-

- {t('shareAccessHint')} -

- -
- -
- - -
- -
-
- - - -
- - {accessTokenId && accessToken && ( -
-
- -
-
- )} -
-
-
- )} -
-
- - - - - - -
-
- - ); -} diff --git a/src/app/[orgId]/settings/share-links/ShareLinksTable.tsx b/src/app/[orgId]/settings/share-links/ShareLinksTable.tsx deleted file mode 100644 index 2943311f..00000000 --- a/src/app/[orgId]/settings/share-links/ShareLinksTable.tsx +++ /dev/null @@ -1,298 +0,0 @@ -"use client"; - -import { ColumnDef } from "@tanstack/react-table"; -import { ShareLinksDataTable } from "@app/components/ShareLinksDataTable"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger -} from "@app/components/ui/dropdown-menu"; -import { Button } from "@app/components/ui/button"; -import { - Copy, - ArrowRight, - ArrowUpDown, - MoreHorizontal, - Check, - ArrowUpRight, - ShieldOff, - ShieldCheck -} from "lucide-react"; -import Link from "next/link"; -import { useRouter } from "next/navigation"; -// import CreateResourceForm from "./CreateResourceForm"; -import { useState } from "react"; -import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; -import { formatAxiosError } from "@app/lib/api"; -import { toast } from "@app/hooks/useToast"; -import { createApiClient } from "@app/lib/api"; -import { useEnvContext } from "@app/hooks/useEnvContext"; -import { ArrayElement } from "@server/types/ArrayElement"; -import { ListAccessTokensResponse } from "@server/routers/accessToken"; -import moment from "moment"; -import CreateShareLinkForm from "@app/components/CreateShareLinkForm"; -import { constructShareLink } from "@app/lib/shareLinks"; -import { useTranslations } from "next-intl"; - -export type ShareLinkRow = { - accessTokenId: string; - resourceId: number; - resourceName: string; - title: string | null; - createdAt: number; - expiresAt: number | null; -}; - -type ShareLinksTableProps = { - shareLinks: ShareLinkRow[]; - orgId: string; -}; - -export default function ShareLinksTable({ - shareLinks, - orgId -}: ShareLinksTableProps) { - const router = useRouter(); - const t = useTranslations(); - - const api = createApiClient(useEnvContext()); - - const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); - const [rows, setRows] = useState(shareLinks); - - function formatLink(link: string) { - return link.substring(0, 20) + "..." + link.substring(link.length - 20); - } - - async function deleteSharelink(id: string) { - await api.delete(`/access-token/${id}`).catch((e) => { - toast({ - title: t("shareErrorDelete"), - description: formatAxiosError(e, t("shareErrorDeleteMessage")) - }); - }); - - const newRows = rows.filter((r) => r.accessTokenId !== id); - setRows(newRows); - - toast({ - title: t("shareDeleted"), - description: t("shareDeletedDescription") - }); - } - - const columns: ColumnDef[] = [ - { - accessorKey: "resourceName", - header: ({ column }) => { - return ( - - ); - }, - cell: ({ row }) => { - const r = row.original; - return ( - - - - ); - } - }, - { - accessorKey: "title", - header: ({ column }) => { - return ( - - ); - } - }, - // { - // accessorKey: "domain", - // header: "Link", - // cell: ({ row }) => { - // const r = row.original; - // - // const link = constructShareLink( - // r.resourceId, - // r.accessTokenId, - // r.tokenHash - // ); - // - // return ( - //
- // - // {formatLink(link)} - // - // - //
- // ); - // } - // }, - { - accessorKey: "createdAt", - header: ({ column }) => { - return ( - - ); - }, - cell: ({ row }) => { - const r = row.original; - return moment(r.createdAt).format("lll"); - } - }, - { - accessorKey: "expiresAt", - header: ({ column }) => { - return ( - - ); - }, - cell: ({ row }) => { - const r = row.original; - if (r.expiresAt) { - return moment(r.expiresAt).format("lll"); - } - return t("never"); - } - }, - { - id: "delete", - cell: ({ row }) => { - const resourceRow = row.original; - return ( -
- {/* */} - {/* */} - {/* */} - {/* */} - {/* */} - {/* { */} - {/* deleteSharelink( */} - {/* resourceRow.accessTokenId */} - {/* ); */} - {/* }} */} - {/* > */} - {/* */} - {/* */} - {/* */} - {/* */} - -
- ); - } - } - ]; - - return ( - <> - { - setRows([val, ...rows]); - }} - /> - - { - setIsCreateModalOpen(true); - }} - /> - - ); -} diff --git a/src/app/[orgId]/settings/share-links/page.tsx b/src/app/[orgId]/settings/share-links/page.tsx index be561acd..caf02b83 100644 --- a/src/app/[orgId]/settings/share-links/page.tsx +++ b/src/app/[orgId]/settings/share-links/page.tsx @@ -8,7 +8,6 @@ import { GetOrgResponse } from "@server/routers/org"; import OrgProvider from "@app/providers/OrgProvider"; import { ListAccessTokensResponse } from "@server/routers/accessToken"; import ShareLinksTable, { ShareLinkRow } from "../../../../components/ShareLinksTable"; -import ShareableLinksSplash from "../../../../components/ShareLinksSplash"; import { getTranslations } from "next-intl/server"; type ShareLinksPageProps = { From bc151df6387e7c4a77eeb1b10d5a9af7044981f6 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Sat, 6 Sep 2025 22:24:22 -0700 Subject: [PATCH 021/166] auto detect public ip --- install/main.go | 36 ++++++++++++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/install/main.go b/install/main.go index 1d684b51..33606250 100644 --- a/install/main.go +++ b/install/main.go @@ -8,6 +8,8 @@ import ( "io" "io/fs" "math/rand" + "net" + "net/http" "os" "os/exec" "path/filepath" @@ -15,7 +17,6 @@ import ( "strings" "text/template" "time" - "net" ) // DO NOT EDIT THIS FUNCTION; IT MATCHED BY REGEX IN CICD @@ -328,7 +329,12 @@ func collectUserInput(reader *bufio.Reader) Config { config.HybridSecret = readString(reader, "Enter your secret", "") } - config.DashboardDomain = readString(reader, "The public addressable IP address for this node or a domain pointing to it", "") + // Try to get public IP as default + publicIP := getPublicIP() + if publicIP != "" { + fmt.Printf("Detected public IP: %s\n", publicIP) + } + config.DashboardDomain = readString(reader, "The public addressable IP address for this node or a domain pointing to it", publicIP) config.InstallGerbil = true } else { config.BaseDomain = readString(reader, "Enter your base domain (no subdomain e.g. example.com)", "") @@ -584,6 +590,32 @@ func generateRandomSecretKey() string { return string(b) } +func getPublicIP() string { + client := &http.Client{ + Timeout: 10 * time.Second, + } + + resp, err := client.Get("https://ifconfig.io/ip") + if err != nil { + return "" + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "" + } + + ip := strings.TrimSpace(string(body)) + + // Validate that it's a valid IP address + if net.ParseIP(ip) != nil { + return ip + } + + return "" +} + // Run external commands with stdio/stderr attached. func run(name string, args ...string) error { cmd := exec.Command(name, args...) From 60d392742d08244d3cf8e546a7924e6c85f5e14e Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Sat, 6 Sep 2025 22:37:02 -0700 Subject: [PATCH 022/166] update install link --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 287f5e20..bcfef4d4 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ _Pangolin tunnels your services to the internet so you can access anything from Website
| - + Install Guide | From cbee67e475dcf969196a8f2db1927366a96f83d9 Mon Sep 17 00:00:00 2001 From: Pallavi Date: Sun, 7 Sep 2025 22:47:06 +0530 Subject: [PATCH 023/166] Include region field in ExitNode query to match schema --- server/lib/exitNodes/exitNodes.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/server/lib/exitNodes/exitNodes.ts b/server/lib/exitNodes/exitNodes.ts index 06539bb0..7b571682 100644 --- a/server/lib/exitNodes/exitNodes.ts +++ b/server/lib/exitNodes/exitNodes.ts @@ -30,7 +30,8 @@ export async function listExitNodes(orgId: string, filterOnline = false) { maxConnections: exitNodes.maxConnections, online: exitNodes.online, lastPing: exitNodes.lastPing, - type: exitNodes.type + type: exitNodes.type, + region: exitNodes.region }) .from(exitNodes); From 068d8484fb3af07816885f32a6bc3719a61ded36 Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 7 Sep 2025 10:46:44 -0700 Subject: [PATCH 024/166] Fix #1423 --- .../integration/verifyApiKeySetResourceUsers.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/server/middlewares/integration/verifyApiKeySetResourceUsers.ts b/server/middlewares/integration/verifyApiKeySetResourceUsers.ts index 9c96e6ec..51a8f3fc 100644 --- a/server/middlewares/integration/verifyApiKeySetResourceUsers.ts +++ b/server/middlewares/integration/verifyApiKeySetResourceUsers.ts @@ -19,6 +19,11 @@ export async function verifyApiKeySetResourceUsers( ); } + if (apiKey.isRoot) { + // Root keys can access any key in any org + return next(); + } + if (!req.apiKeyOrg) { return next( createHttpError( @@ -32,11 +37,6 @@ export async function verifyApiKeySetResourceUsers( return next(createHttpError(HttpCode.BAD_REQUEST, "Invalid user IDs")); } - if (apiKey.isRoot) { - // Root keys can access any key in any org - return next(); - } - if (userIds.length === 0) { return next(); } From a6df41cb434c59ae4992697d573f653702886017 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 8 Sep 2025 17:37:30 -0700 Subject: [PATCH 025/166] Add resource niceId --- server/db/pg/schema.ts | 2 +- server/db/sqlite/schema.ts | 2 +- server/routers/external.ts | 6 ++ server/routers/resource/getResource.ts | 73 ++++++++++++++------- server/routers/resource/listResources.ts | 4 +- src/app/[orgId]/settings/resources/page.tsx | 6 +- src/components/ResourcesTable.tsx | 20 +++++- 7 files changed, 84 insertions(+), 29 deletions(-) diff --git a/server/db/pg/schema.ts b/server/db/pg/schema.ts index 1f571ab4..1e634cc9 100644 --- a/server/db/pg/schema.ts +++ b/server/db/pg/schema.ts @@ -71,7 +71,7 @@ export const resources = pgTable("resources", { onDelete: "cascade" }) .notNull(), - niceId: text("niceId"), // TODO: SHOULD THIS BE NULLABLE? + niceId: text("niceId").notNull(), name: varchar("name").notNull(), subdomain: varchar("subdomain"), fullDomain: varchar("fullDomain"), diff --git a/server/db/sqlite/schema.ts b/server/db/sqlite/schema.ts index 90b09734..2db0fdc4 100644 --- a/server/db/sqlite/schema.ts +++ b/server/db/sqlite/schema.ts @@ -77,7 +77,7 @@ export const resources = sqliteTable("resources", { onDelete: "cascade" }) .notNull(), - niceId: text("niceId"), // TODO: SHOULD THIS BE NULLABLE? + niceId: text("niceId").notNull(), name: text("name").notNull(), subdomain: text("subdomain"), fullDomain: text("fullDomain"), diff --git a/server/routers/external.ts b/server/routers/external.ts index 6d00eb3c..22bfe8bb 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -343,6 +343,12 @@ authenticated.get( verifyUserHasAction(ActionsEnum.getResource), resource.getResource ); +authenticated.get( + "/org/:orgId/resource/:niceId", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.getResource), + resource.getResource +); authenticated.post( "/resource/:resourceId", verifyResourceAccess, diff --git a/server/routers/resource/getResource.ts b/server/routers/resource/getResource.ts index a2c1c0d1..d2aebedd 100644 --- a/server/routers/resource/getResource.ts +++ b/server/routers/resource/getResource.ts @@ -2,32 +2,72 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; import { Resource, resources, sites } from "@server/db"; -import { eq } from "drizzle-orm"; +import { eq, and } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import { fromError } from "zod-validation-error"; import logger from "@server/logger"; +import stoi from "@server/lib/stoi"; import { OpenAPITags, registry } from "@server/openApi"; const getResourceSchema = z .object({ resourceId: z .string() - .transform(Number) - .pipe(z.number().int().positive()) + .optional() + .transform(stoi) + .pipe(z.number().int().positive().optional()) + .optional(), + niceId: z.string().optional(), + orgId: z.string().optional() }) .strict(); -export type GetResourceResponse = Resource; +async function query(resourceId?: number, niceId?: string, orgId?: string) { + if (resourceId) { + const [res] = await db + .select() + .from(resources) + .where(eq(resources.resourceId, resourceId)) + .limit(1); + return res; + } else if (niceId && orgId) { + const [res] = await db + .select() + .from(resources) + .where(and(eq(resources.niceId, niceId), eq(resources.orgId, orgId))) + .limit(1); + return res; + } +} + +export type GetResourceResponse = NonNullable>>; + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/resource/{niceId}", + description: + "Get a resource by orgId and niceId. NiceId is a readable ID for the resource and unique on a per org basis.", + tags: [OpenAPITags.Org, OpenAPITags.Resource], + request: { + params: z.object({ + orgId: z.string(), + niceId: z.string() + }) + }, + responses: {} +}); registry.registerPath({ method: "get", path: "/resource/{resourceId}", - description: "Get a resource.", + description: "Get a resource by resourceId.", tags: [OpenAPITags.Resource], request: { - params: getResourceSchema + params: z.object({ + resourceId: z.number() + }) }, responses: {} }); @@ -48,29 +88,18 @@ export async function getResource( ); } - const { resourceId } = parsedParams.data; + const { resourceId, niceId, orgId } = parsedParams.data; - const [resp] = await db - .select() - .from(resources) - .where(eq(resources.resourceId, resourceId)) - .limit(1); - - const resource = resp; + const resource = await query(resourceId, niceId, orgId); if (!resource) { return next( - createHttpError( - HttpCode.NOT_FOUND, - `Resource with ID ${resourceId} not found` - ) + createHttpError(HttpCode.NOT_FOUND, "Resource not found") ); } - return response(res, { - data: { - ...resource - }, + return response(res, { + data: resource, success: true, error: false, message: "Resource retrieved successfully", diff --git a/server/routers/resource/listResources.ts b/server/routers/resource/listResources.ts index 54878bfc..45d225ed 100644 --- a/server/routers/resource/listResources.ts +++ b/server/routers/resource/listResources.ts @@ -16,6 +16,7 @@ import logger from "@server/logger"; import stoi from "@server/lib/stoi"; import { fromZodError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; +import { warn } from "console"; const listResourcesParamsSchema = z .object({ @@ -54,7 +55,8 @@ function queryResources(accessibleResourceIds: number[], orgId: string) { protocol: resources.protocol, proxyPort: resources.proxyPort, enabled: resources.enabled, - domainId: resources.domainId + domainId: resources.domainId, + niceId: resources.niceId }) .from(resources) .leftJoin( diff --git a/src/app/[orgId]/settings/resources/page.tsx b/src/app/[orgId]/settings/resources/page.tsx index b1b907e6..97abdd4c 100644 --- a/src/app/[orgId]/settings/resources/page.tsx +++ b/src/app/[orgId]/settings/resources/page.tsx @@ -76,8 +76,7 @@ export default async function ResourcesPage(props: ResourcesPageProps) { id: resource.resourceId, name: resource.name, orgId: params.orgId, - - + nice: resource.niceId, domain: `${resource.ssl ? "https://" : "http://"}${toUnicode(resource.fullDomain || "")}`, protocol: resource.protocol, proxyPort: resource.proxyPort, @@ -91,7 +90,8 @@ export default async function ResourcesPage(props: ResourcesPageProps) { ? "protected" : "not_protected", enabled: resource.enabled, - domainId: resource.domainId || undefined + domainId: resource.domainId || undefined, + ssl: resource.ssl }; }); diff --git a/src/components/ResourcesTable.tsx b/src/components/ResourcesTable.tsx index 622d42da..8a62c14b 100644 --- a/src/components/ResourcesTable.tsx +++ b/src/components/ResourcesTable.tsx @@ -66,6 +66,7 @@ import { Alert, AlertDescription } from "@app/components/ui/alert"; export type ResourceRow = { id: number; + nice: string | null; name: string; orgId: string; domain: string; @@ -75,6 +76,7 @@ export type ResourceRow = { proxyPort: number | null; enabled: boolean; domainId?: string; + ssl: boolean; }; export type InternalResourceRow = { @@ -336,12 +338,28 @@ export default function ResourcesTable({ ); } }, + { + accessorKey: "nice", + header: ({ column }) => { + return ( + + ); + } + }, { accessorKey: "protocol", header: t("protocol"), cell: ({ row }) => { const resourceRow = row.original; - return {resourceRow.protocol.toUpperCase()}; + return {resourceRow.http ? (resourceRow.ssl ? "HTTPS" : "HTTP") : resourceRow.protocol.toUpperCase()}; } }, { From d1890a27562c37cfa941fd7f354aa731d231bdd2 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 8 Sep 2025 17:50:07 -0700 Subject: [PATCH 026/166] Resource identified with niceId now --- server/db/names.ts | 21 ++++++++++++++++++- server/routers/resource/createResource.ts | 7 +++++++ .../authentication/page.tsx | 0 .../general/page.tsx | 0 .../{[resourceId] => [niceId]}/layout.tsx | 12 +++++------ .../{[resourceId] => [niceId]}/page.tsx | 4 ++-- .../{[resourceId] => [niceId]}/proxy/page.tsx | 6 +++--- .../{[resourceId] => [niceId]}/rules/page.tsx | 10 ++++----- src/components/ResourcesTable.tsx | 2 +- 9 files changed, 44 insertions(+), 18 deletions(-) rename src/app/[orgId]/settings/resources/{[resourceId] => [niceId]}/authentication/page.tsx (100%) rename src/app/[orgId]/settings/resources/{[resourceId] => [niceId]}/general/page.tsx (100%) rename src/app/[orgId]/settings/resources/{[resourceId] => [niceId]}/layout.tsx (89%) rename src/app/[orgId]/settings/resources/{[resourceId] => [niceId]}/page.tsx (53%) rename src/app/[orgId]/settings/resources/{[resourceId] => [niceId]}/proxy/page.tsx (99%) rename src/app/[orgId]/settings/resources/{[resourceId] => [niceId]}/rules/page.tsx (98%) diff --git a/server/db/names.ts b/server/db/names.ts index 41f4c170..9c671a22 100644 --- a/server/db/names.ts +++ b/server/db/names.ts @@ -1,6 +1,6 @@ import { join } from "path"; import { readFileSync } from "fs"; -import { db } from "@server/db"; +import { db, resources } from "@server/db"; import { exitNodes, sites } from "@server/db"; import { eq, and } from "drizzle-orm"; import { __DIRNAME } from "@server/lib/consts"; @@ -34,6 +34,25 @@ export async function getUniqueSiteName(orgId: string): Promise { } } +export async function getUniqueResourceName(orgId: string): Promise { + let loops = 0; + while (true) { + if (loops > 100) { + throw new Error("Could not generate a unique name"); + } + + const name = generateName(); + const count = await db + .select({ niceId: resources.niceId, orgId: resources.orgId }) + .from(resources) + .where(and(eq(resources.niceId, name), eq(resources.orgId, orgId))); + if (count.length === 0) { + return name; + } + loops++; + } +} + export async function getUniqueExitNodeEndpointName(): Promise { let loops = 0; const count = await db diff --git a/server/routers/resource/createResource.ts b/server/routers/resource/createResource.ts index 5b27cb41..3616140a 100644 --- a/server/routers/resource/createResource.ts +++ b/server/routers/resource/createResource.ts @@ -21,6 +21,7 @@ import { subdomainSchema } from "@server/lib/schemas"; import config from "@server/lib/config"; import { OpenAPITags, registry } from "@server/openApi"; import { build } from "@server/build"; +import { getUniqueResourceName } from "@server/db/names"; const createResourceParamsSchema = z .object({ @@ -283,10 +284,13 @@ async function createHttpResource( let resource: Resource | undefined; + const niceId = await getUniqueResourceName(orgId); + await db.transaction(async (trx) => { const newResource = await trx .insert(resources) .values({ + niceId, fullDomain, domainId, orgId, @@ -391,10 +395,13 @@ async function createRawResource( let resource: Resource | undefined; + const niceId = await getUniqueResourceName(orgId); + await db.transaction(async (trx) => { const newResource = await trx .insert(resources) .values({ + niceId, orgId, name, http, diff --git a/src/app/[orgId]/settings/resources/[resourceId]/authentication/page.tsx b/src/app/[orgId]/settings/resources/[niceId]/authentication/page.tsx similarity index 100% rename from src/app/[orgId]/settings/resources/[resourceId]/authentication/page.tsx rename to src/app/[orgId]/settings/resources/[niceId]/authentication/page.tsx diff --git a/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx b/src/app/[orgId]/settings/resources/[niceId]/general/page.tsx similarity index 100% rename from src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx rename to src/app/[orgId]/settings/resources/[niceId]/general/page.tsx diff --git a/src/app/[orgId]/settings/resources/[resourceId]/layout.tsx b/src/app/[orgId]/settings/resources/[niceId]/layout.tsx similarity index 89% rename from src/app/[orgId]/settings/resources/[resourceId]/layout.tsx rename to src/app/[orgId]/settings/resources/[niceId]/layout.tsx index 8a5d0997..3f8425ce 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/layout.tsx +++ b/src/app/[orgId]/settings/resources/[niceId]/layout.tsx @@ -18,7 +18,7 @@ import { getTranslations } from 'next-intl/server'; interface ResourceLayoutProps { children: React.ReactNode; - params: Promise<{ resourceId: number | string; orgId: string }>; + params: Promise<{ niceId: string; orgId: string }>; } export default async function ResourceLayout(props: ResourceLayoutProps) { @@ -31,7 +31,7 @@ export default async function ResourceLayout(props: ResourceLayoutProps) { let resource = null; try { const res = await internal.get>( - `/resource/${params.resourceId}`, + `/org/${params.orgId}/resource/${params.niceId}`, await authCookieHeader() ); resource = res.data.data; @@ -77,22 +77,22 @@ export default async function ResourceLayout(props: ResourceLayoutProps) { const navItems = [ { title: t('general'), - href: `/{orgId}/settings/resources/{resourceId}/general` + href: `/{orgId}/settings/resources/{niceId}/general` }, { title: t('proxy'), - href: `/{orgId}/settings/resources/{resourceId}/proxy` + href: `/{orgId}/settings/resources/{niceId}/proxy` } ]; if (resource.http) { navItems.push({ title: t('authentication'), - href: `/{orgId}/settings/resources/{resourceId}/authentication` + href: `/{orgId}/settings/resources/{niceId}/authentication` }); navItems.push({ title: t('rules'), - href: `/{orgId}/settings/resources/{resourceId}/rules` + href: `/{orgId}/settings/resources/{niceId}/rules` }); } diff --git a/src/app/[orgId]/settings/resources/[resourceId]/page.tsx b/src/app/[orgId]/settings/resources/[niceId]/page.tsx similarity index 53% rename from src/app/[orgId]/settings/resources/[resourceId]/page.tsx rename to src/app/[orgId]/settings/resources/[niceId]/page.tsx index a0d45a94..41995661 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/page.tsx +++ b/src/app/[orgId]/settings/resources/[niceId]/page.tsx @@ -1,10 +1,10 @@ import { redirect } from "next/navigation"; export default async function ResourcePage(props: { - params: Promise<{ resourceId: number | string; orgId: string }>; + params: Promise<{ niceId: string; orgId: string }>; }) { const params = await props.params; redirect( - `/${params.orgId}/settings/resources/${params.resourceId}/proxy` + `/${params.orgId}/settings/resources/${params.niceId}/proxy` ); } diff --git a/src/app/[orgId]/settings/resources/[resourceId]/proxy/page.tsx b/src/app/[orgId]/settings/resources/[niceId]/proxy/page.tsx similarity index 99% rename from src/app/[orgId]/settings/resources/[resourceId]/proxy/page.tsx rename to src/app/[orgId]/settings/resources/[niceId]/proxy/page.tsx index 87c3dd13..27c795ef 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/proxy/page.tsx +++ b/src/app/[orgId]/settings/resources/[niceId]/proxy/page.tsx @@ -256,7 +256,7 @@ export default function ReverseProxyTargets(props: { const fetchTargets = async () => { try { const res = await api.get>( - `/resource/${params.resourceId}/targets` + `/resource/${resource.resourceId}/targets` ); if (res.status === 200) { @@ -447,7 +447,7 @@ export default function ReverseProxyTargets(props: { if (target.new) { const res = await api.put< AxiosResponse - >(`/resource/${params.resourceId}/target`, data); + >(`/resource/${resource.resourceId}/target`, data); target.targetId = res.data.data.targetId; target.new = false; } else if (target.updated) { @@ -475,7 +475,7 @@ export default function ReverseProxyTargets(props: { }; // Single API call to update all settings - await api.post(`/resource/${params.resourceId}`, payload); + await api.post(`/resource/${resource.resourceId}`, payload); // Update local resource context updateResource({ diff --git a/src/app/[orgId]/settings/resources/[resourceId]/rules/page.tsx b/src/app/[orgId]/settings/resources/[niceId]/rules/page.tsx similarity index 98% rename from src/app/[orgId]/settings/resources/[resourceId]/rules/page.tsx rename to src/app/[orgId]/settings/resources/[niceId]/rules/page.tsx index 424d7973..8b5e4709 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/rules/page.tsx +++ b/src/app/[orgId]/settings/resources/[niceId]/rules/page.tsx @@ -128,7 +128,7 @@ export default function ResourceRules(props: { try { const res = await api.get< AxiosResponse - >(`/resource/${params.resourceId}/rules`); + >(`/resource/${resource.resourceId}/rules`); if (res.status === 200) { setRules(res.data.data.rules); } @@ -251,7 +251,7 @@ export default function ResourceRules(props: { // Save rules enabled state const res = await api - .post(`/resource/${params.resourceId}`, { + .post(`/resource/${resource.resourceId}`, { applyRules: rulesEnabled }) .catch((err) => { @@ -336,13 +336,13 @@ export default function ResourceRules(props: { if (rule.new) { const res = await api.put( - `/resource/${params.resourceId}/rule`, + `/resource/${resource.resourceId}/rule`, data ); rule.ruleId = res.data.data.ruleId; } else if (rule.updated) { await api.post( - `/resource/${params.resourceId}/rule/${rule.ruleId}`, + `/resource/${resource.resourceId}/rule/${rule.ruleId}`, data ); } @@ -361,7 +361,7 @@ export default function ResourceRules(props: { for (const ruleId of rulesToRemove) { await api.delete( - `/resource/${params.resourceId}/rule/${ruleId}` + `/resource/${resource.resourceId}/rule/${ruleId}` ); setRules(rules.filter((r) => r.ruleId !== ruleId)); } diff --git a/src/components/ResourcesTable.tsx b/src/components/ResourcesTable.tsx index 8a62c14b..3352c4fa 100644 --- a/src/components/ResourcesTable.tsx +++ b/src/components/ResourcesTable.tsx @@ -481,7 +481,7 @@ export default function ResourcesTable({
); } }, ...(resource.http ? [ - { - accessorKey: "method", - header: t("method"), - cell: ({ row }: { row: Row }) => ( - - ) - } - ] + { + accessorKey: "method", + header: t("method"), + cell: ({ row }: { row: Row }) => ( + + ) + } + ] : []), { accessorKey: "ip", @@ -658,9 +679,13 @@ export default function ReverseProxyTargets(props: { if (parsed) { updateTarget(row.original.targetId, { ...row.original, - method: hasProtocol ? parsed.protocol : row.original.method, + method: hasProtocol + ? parsed.protocol + : row.original.method, ip: parsed.host, - port: hasPort ? parsed.port : row.original.port + port: hasPort + ? parsed.port + : row.original.port }); } else { updateTarget(row.original.targetId, { @@ -807,21 +832,21 @@ export default function ReverseProxyTargets(props: { className={cn( "justify-between flex-1", !field.value && - "text-muted-foreground" + "text-muted-foreground" )} > {field.value ? sites.find( - ( - site - ) => - site.siteId === - field.value - ) - ?.name + ( + site + ) => + site.siteId === + field.value + ) + ?.name : t( - "siteSelect" - )} + "siteSelect" + )} @@ -887,18 +912,35 @@ export default function ReverseProxyTargets(props: { ); return selectedSite && selectedSite.type === - "newt" ? (() => { - const dockerState = getDockerStateForSite(selectedSite.siteId); - return ( - refreshContainersForSite(selectedSite.siteId)} - /> - ); - })() : null; + "newt" + ? (() => { + const dockerState = + getDockerStateForSite( + selectedSite.siteId + ); + return ( + + refreshContainersForSite( + selectedSite.siteId + ) + } + /> + ); + })() + : null; })()}
@@ -964,25 +1006,59 @@ export default function ReverseProxyTargets(props: { name="ip" render={({ field }) => ( - {t("targetAddr")} + + {t("targetAddr")} + { - const input = e.target.value.trim(); - const hasProtocol = /^(https?|h2c):\/\//.test(input); - const hasPort = /:\d+(?:\/|$)/.test(input); + const input = + e.target.value.trim(); + const hasProtocol = + /^(https?|h2c):\/\//.test( + input + ); + const hasPort = + /:\d+(?:\/|$)/.test( + input + ); - if (hasProtocol || hasPort) { - const parsed = parseHostTarget(input); + if ( + hasProtocol || + hasPort + ) { + const parsed = + parseHostTarget( + input + ); if (parsed) { - if (hasProtocol || !addTargetForm.getValues("method")) { - addTargetForm.setValue("method", parsed.protocol); + if ( + hasProtocol || + !addTargetForm.getValues( + "method" + ) + ) { + addTargetForm.setValue( + "method", + parsed.protocol + ); } - addTargetForm.setValue("ip", parsed.host); - if (hasPort || !addTargetForm.getValues("port")) { - addTargetForm.setValue("port", parsed.port); + addTargetForm.setValue( + "ip", + parsed.host + ); + if ( + hasPort || + !addTargetForm.getValues( + "port" + ) + ) { + addTargetForm.setValue( + "port", + parsed.port + ); } } } else { @@ -1091,12 +1167,12 @@ export default function ReverseProxyTargets(props: { {header.isPlaceholder ? null : flexRender( - header - .column - .columnDef - .header, - header.getContext() - )} + header + .column + .columnDef + .header, + header.getContext() + )} ) )} @@ -1256,6 +1332,36 @@ export default function ReverseProxyTargets(props: { )} /> + ( + + + {t("customHeaders")} + + + { + field.onChange( + value + ); + }} + rows={4} + /> + + + {t( + "customHeadersDescription" + )} + + + + )} + /> diff --git a/src/components/HeadersInput.tsx b/src/components/HeadersInput.tsx new file mode 100644 index 00000000..35f3e587 --- /dev/null +++ b/src/components/HeadersInput.tsx @@ -0,0 +1,69 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Textarea } from "@/components/ui/textarea"; + +interface HeadersInputProps { + value?: string; + onChange: (value: string) => void; + placeholder?: string; + rows?: number; + className?: string; +} + +export function HeadersInput({ + value = "", + onChange, + placeholder = `X-Example-Header: example-value +X-Another-Header: another-value`, + rows = 4, + className +}: HeadersInputProps) { + const [internalValue, setInternalValue] = useState(""); + + // Convert comma-separated to newline-separated for display + const convertToNewlineSeparated = (commaSeparated: string): string => { + if (!commaSeparated || commaSeparated.trim() === "") return ""; + + return commaSeparated + .split(',') + .map(header => header.trim()) + .filter(header => header.length > 0) + .join('\n'); + }; + + // Convert newline-separated to comma-separated for output + const convertToCommaSeparated = (newlineSeparated: string): string => { + if (!newlineSeparated || newlineSeparated.trim() === "") return ""; + + return newlineSeparated + .split('\n') + .map(header => header.trim()) + .filter(header => header.length > 0) + .join(', '); + }; + + // Update internal value when external value changes + useEffect(() => { + setInternalValue(convertToNewlineSeparated(value)); + }, [value]); + + const handleChange = (e: React.ChangeEvent) => { + const newValue = e.target.value; + setInternalValue(newValue); + + // Convert back to comma-separated format for the parent + const commaSeparatedValue = convertToCommaSeparated(newValue); + onChange(commaSeparatedValue); + }; + + return ( +