Compare commits

...

12 Commits
1.16.1 ... dev

Author SHA1 Message Date
Milo Schwartz
280cbb6e22 Merge pull request #2553 from LaurenceJJones/explore/static-org-dropdown
enhance(sidebar): make mobile org selector sticky
2026-03-01 11:14:16 -08:00
miloschwartz
c20babcb53 fix org selector spacing on mobile 2026-03-01 11:13:49 -08:00
Owen Schwartz
768eebe2cd Merge pull request #2432 from ChanningHe/feat-integration-api-domain-crud
feat(integration): add domain CRUD endpoints to integration API
2026-03-01 11:12:05 -08:00
Owen
66c377a5c9 Merge branch 'main' into dev 2026-02-28 12:14:41 -08:00
Owen
50c2aa0111 Add default memory limits 2026-02-28 12:14:27 -08:00
Owen
fdeb891137 Fix pagination effecting drop downs 2026-02-28 12:07:42 -08:00
Owen Schwartz
6a6e3a43b1 Merge pull request #2562 from LaurenceJJones/fix/zod-openapi-catch-error
fix(zod): Add openapi call after catch
2026-02-28 11:04:10 -08:00
Laurence
b0a34fa21b fix(openapi): Add openapi call after catch
fix: #2561
without making an explicit call to openapi a runtime error happens because it cannot infer the type, the call to openapi is the same across the codebase
2026-02-28 11:27:19 +00:00
Owen
72bf6f3c41 Comma seperated 2026-02-27 17:53:44 -08:00
miloschwartz
ad9289e0c1 sort by name by default 2026-02-27 15:53:27 -08:00
Laurence
81c1a1da9c enhance(sidebar): make mobile org selector sticky
Make org selector sticky on mobile sidebar

  Move OrgSelector outside the scrollable container so it stays fixed
  at the top while menu items scroll, matching the desktop sidebar
  behavior introduced in 9b2c0d0b.
2026-02-26 15:45:41 +00:00
ChanningHe
52f26396ac feat(integration): add domain CRUD endpoints to integration API 2026-02-26 08:44:55 +09:00
17 changed files with 431 additions and 230 deletions

View File

@@ -4,6 +4,12 @@ services:
image: fosrl/pangolin:latest image: fosrl/pangolin:latest
container_name: pangolin container_name: pangolin
restart: unless-stopped restart: unless-stopped
deploy:
resources:
limits:
memory: 1g
reservations:
memory: 256m
volumes: volumes:
- ./config:/app/config - ./config:/app/config
healthcheck: healthcheck:

View File

@@ -4,6 +4,12 @@ services:
image: docker.io/fosrl/pangolin:{{if .IsEnterprise}}ee-{{end}}{{.PangolinVersion}} image: docker.io/fosrl/pangolin:{{if .IsEnterprise}}ee-{{end}}{{.PangolinVersion}}
container_name: pangolin container_name: pangolin
restart: unless-stopped restart: unless-stopped
deploy:
resources:
limits:
memory: 1g
reservations:
memory: 256m
volumes: volumes:
- ./config:/app/config - ./config:/app/config
healthcheck: healthcheck:

View File

@@ -1102,6 +1102,12 @@
"actionGetUser": "Get User", "actionGetUser": "Get User",
"actionGetOrgUser": "Get Organization User", "actionGetOrgUser": "Get Organization User",
"actionListOrgDomains": "List Organization Domains", "actionListOrgDomains": "List Organization Domains",
"actionGetDomain": "Get Domain",
"actionCreateOrgDomain": "Create Domain",
"actionUpdateOrgDomain": "Update Domain",
"actionDeleteOrgDomain": "Delete Domain",
"actionGetDNSRecords": "Get DNS Records",
"actionRestartOrgDomain": "Restart Domain",
"actionCreateSite": "Create Site", "actionCreateSite": "Create Site",
"actionDeleteSite": "Delete Site", "actionDeleteSite": "Delete Site",
"actionGetSite": "Get Site", "actionGetSite": "Get Site",
@@ -1670,10 +1676,10 @@
"sshSudoModeCommandsDescription": "User can run only the specified commands with sudo.", "sshSudoModeCommandsDescription": "User can run only the specified commands with sudo.",
"sshSudo": "Allow sudo", "sshSudo": "Allow sudo",
"sshSudoCommands": "Sudo Commands", "sshSudoCommands": "Sudo Commands",
"sshSudoCommandsDescription": "List of commands the user is allowed to run with sudo.", "sshSudoCommandsDescription": "Comma separated list of commands the user is allowed to run with sudo.",
"sshCreateHomeDir": "Create Home Directory", "sshCreateHomeDir": "Create Home Directory",
"sshUnixGroups": "Unix Groups", "sshUnixGroups": "Unix Groups",
"sshUnixGroupsDescription": "Unix groups to add the user to on the target host.", "sshUnixGroupsDescription": "Comma separated Unix groups to add the user to on the target host.",
"retryAttempts": "Retry Attempts", "retryAttempts": "Retry Attempts",
"expectedResponseCodes": "Expected Response Codes", "expectedResponseCodes": "Expected Response Codes",
"expectedResponseCodesDescription": "HTTP status code that indicates healthy status. If left blank, 200-300 is considered healthy.", "expectedResponseCodesDescription": "HTTP status code that indicates healthy status. If left blank, 200-300 is considered healthy.",

View File

@@ -14,3 +14,4 @@ export * from "./verifyApiKeyApiKeyAccess";
export * from "./verifyApiKeyClientAccess"; export * from "./verifyApiKeyClientAccess";
export * from "./verifyApiKeySiteResourceAccess"; export * from "./verifyApiKeySiteResourceAccess";
export * from "./verifyApiKeyIdpAccess"; export * from "./verifyApiKeyIdpAccess";
export * from "./verifyApiKeyDomainAccess";

View File

@@ -0,0 +1,90 @@
import { Request, Response, NextFunction } from "express";
import { db, domains, orgDomains, apiKeyOrg } from "@server/db";
import { and, eq } from "drizzle-orm";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
export async function verifyApiKeyDomainAccess(
req: Request,
res: Response,
next: NextFunction
) {
try {
const apiKey = req.apiKey;
const domainId =
req.params.domainId || req.body.domainId || req.query.domainId;
const orgId = req.params.orgId;
if (!apiKey) {
return next(
createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated")
);
}
if (!domainId) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid domain ID")
);
}
if (apiKey.isRoot) {
// Root keys can access any domain in any org
return next();
}
// Verify domain exists and belongs to the organization
const [domain] = await db
.select()
.from(domains)
.innerJoin(orgDomains, eq(orgDomains.domainId, domains.domainId))
.where(
and(
eq(orgDomains.domainId, domainId),
eq(orgDomains.orgId, orgId)
)
)
.limit(1);
if (!domain) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Domain with ID ${domainId} not found in organization ${orgId}`
)
);
}
// Verify the API key has access to this organization
if (!req.apiKeyOrg) {
const apiKeyOrgRes = await db
.select()
.from(apiKeyOrg)
.where(
and(
eq(apiKeyOrg.apiKeyId, apiKey.apiKeyId),
eq(apiKeyOrg.orgId, orgId)
)
)
.limit(1);
req.apiKeyOrg = apiKeyOrgRes[0];
}
if (!req.apiKeyOrg) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"Key does not have access to this organization"
)
);
}
return next();
} catch (error) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Error verifying domain access"
)
);
}
}

View File

@@ -370,7 +370,7 @@ export async function listClients(
? order === "asc" ? order === "asc"
? asc(clients[sort_by]) ? asc(clients[sort_by])
: desc(clients[sort_by]) : desc(clients[sort_by])
: asc(clients.clientId) : asc(clients.name)
); );
const [clientsList, totalCount] = await Promise.all([ const [clientsList, totalCount] = await Promise.all([

View File

@@ -27,7 +27,8 @@ import {
verifyApiKeyClientAccess, verifyApiKeyClientAccess,
verifyApiKeySiteResourceAccess, verifyApiKeySiteResourceAccess,
verifyApiKeySetResourceClients, verifyApiKeySetResourceClients,
verifyLimits verifyLimits,
verifyApiKeyDomainAccess
} from "@server/middlewares"; } from "@server/middlewares";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import { Router } from "express"; import { Router } from "express";
@@ -347,6 +348,56 @@ authenticated.get(
domain.listDomains domain.listDomains
); );
authenticated.get(
"/org/:orgId/domain/:domainId",
verifyApiKeyOrgAccess,
verifyApiKeyDomainAccess,
verifyApiKeyHasAction(ActionsEnum.getDomain),
domain.getDomain
);
authenticated.put(
"/org/:orgId/domain",
verifyApiKeyOrgAccess,
verifyApiKeyHasAction(ActionsEnum.createOrgDomain),
logActionAudit(ActionsEnum.createOrgDomain),
domain.createOrgDomain
);
authenticated.patch(
"/org/:orgId/domain/:domainId",
verifyApiKeyOrgAccess,
verifyApiKeyDomainAccess,
verifyApiKeyHasAction(ActionsEnum.updateOrgDomain),
domain.updateOrgDomain
);
authenticated.delete(
"/org/:orgId/domain/:domainId",
verifyApiKeyOrgAccess,
verifyApiKeyDomainAccess,
verifyApiKeyHasAction(ActionsEnum.deleteOrgDomain),
logActionAudit(ActionsEnum.deleteOrgDomain),
domain.deleteAccountDomain
);
authenticated.get(
"/org/:orgId/domain/:domainId/dns-records",
verifyApiKeyOrgAccess,
verifyApiKeyDomainAccess,
verifyApiKeyHasAction(ActionsEnum.getDNSRecords),
domain.getDNSRecords
);
authenticated.post(
"/org/:orgId/domain/:domainId/restart",
verifyApiKeyOrgAccess,
verifyApiKeyDomainAccess,
verifyApiKeyHasAction(ActionsEnum.restartOrgDomain),
logActionAudit(ActionsEnum.restartOrgDomain),
domain.restartOrgDomain
);
authenticated.get( authenticated.get(
"/org/:orgId/invitations", "/org/:orgId/invitations",
verifyApiKeyOrgAccess, verifyApiKeyOrgAccess,

View File

@@ -429,7 +429,7 @@ export async function listResources(
? order === "asc" ? order === "asc"
? asc(resources[sort_by]) ? asc(resources[sort_by])
: desc(resources[sort_by]) : desc(resources[sort_by])
: asc(resources.resourceId) : asc(resources.name)
), ),
countQuery countQuery
]); ]);

View File

@@ -289,7 +289,7 @@ export async function listSites(
? order === "asc" ? order === "asc"
? asc(sites[sort_by]) ? asc(sites[sort_by])
: desc(sites[sort_by]) : desc(sites[sort_by])
: asc(sites.siteId) : asc(sites.name)
); );
const [totalCount, rows] = await Promise.all([ const [totalCount, rows] = await Promise.all([

View File

@@ -205,7 +205,7 @@ export async function listAllSiteResourcesByOrg(
? order === "asc" ? order === "asc"
? asc(siteResources[sort_by]) ? asc(siteResources[sort_by])
: desc(siteResources[sort_by]) : desc(siteResources[sort_by])
: asc(siteResources.siteResourceId) : asc(siteResources.name)
), ),
countQuery countQuery
]); ]);

View File

@@ -31,12 +31,23 @@ const listSiteResourcesQuerySchema = z.object({
sort_by: z sort_by: z
.enum(["name"]) .enum(["name"])
.optional() .optional()
.catch(undefined), .catch(undefined)
.openapi({
type: "string",
enum: ["name"],
description: "Field to sort by"
}),
order: z order: z
.enum(["asc", "desc"]) .enum(["asc", "desc"])
.optional() .optional()
.default("asc") .default("asc")
.catch("asc") .catch("asc")
.openapi({
type: "string",
enum: ["asc", "desc"],
default: "asc",
description: "Sort order"
})
}); });
export type ListSiteResourcesResponse = { export type ListSiteResourcesResponse = {
@@ -112,7 +123,7 @@ export async function listSiteResources(
? order === "asc" ? order === "asc"
? asc(siteResources[sort_by]) ? asc(siteResources[sort_by])
: desc(siteResources[sort_by]) : desc(siteResources[sort_by])
: asc(siteResources.siteResourceId) : asc(siteResources.name)
) )
.limit(limit) .limit(limit)
.offset(offset); .offset(offset);

View File

@@ -89,7 +89,14 @@ import {
} from "lucide-react"; } from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { use, useActionState, useCallback, useEffect, useMemo, useState } from "react"; import {
use,
useActionState,
useCallback,
useEffect,
useMemo,
useState
} from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
@@ -184,29 +191,35 @@ function ProxyResourceTargetsForm({
setDockerStates((prev) => new Map(prev.set(siteId, dockerState))); setDockerStates((prev) => new Map(prev.set(siteId, dockerState)));
}; };
const refreshContainersForSite = useCallback(async (siteId: number) => { const refreshContainersForSite = useCallback(
const dockerManager = new DockerManager(api, siteId); async (siteId: number) => {
const containers = await dockerManager.fetchContainers(); const dockerManager = new DockerManager(api, siteId);
const containers = await dockerManager.fetchContainers();
setDockerStates((prev) => { setDockerStates((prev) => {
const newMap = new Map(prev); const newMap = new Map(prev);
const existingState = newMap.get(siteId); const existingState = newMap.get(siteId);
if (existingState) { if (existingState) {
newMap.set(siteId, { ...existingState, containers }); newMap.set(siteId, { ...existingState, containers });
} }
return newMap; return newMap;
}); });
}, [api]); },
[api]
);
const getDockerStateForSite = useCallback((siteId: number): DockerState => { const getDockerStateForSite = useCallback(
return ( (siteId: number): DockerState => {
dockerStates.get(siteId) || { return (
isEnabled: false, dockerStates.get(siteId) || {
isAvailable: false, isEnabled: false,
containers: [] isAvailable: false,
} containers: []
); }
}, [dockerStates]); );
},
[dockerStates]
);
const [isAdvancedMode, setIsAdvancedMode] = useState(() => { const [isAdvancedMode, setIsAdvancedMode] = useState(() => {
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
@@ -220,7 +233,9 @@ function ProxyResourceTargetsForm({
const removeTarget = useCallback((targetId: number) => { const removeTarget = useCallback((targetId: number) => {
setTargets((prevTargets) => { setTargets((prevTargets) => {
const targetToRemove = prevTargets.find((target) => target.targetId === targetId); const targetToRemove = prevTargets.find(
(target) => target.targetId === targetId
);
if (targetToRemove && !targetToRemove.new) { if (targetToRemove && !targetToRemove.new) {
setTargetsToRemove((prev) => [...prev, targetId]); setTargetsToRemove((prev) => [...prev, targetId]);
} }
@@ -228,21 +243,24 @@ function ProxyResourceTargetsForm({
}); });
}, []); }, []);
const updateTarget = useCallback((targetId: number, data: Partial<LocalTarget>) => { const updateTarget = useCallback(
setTargets((prevTargets) => { (targetId: number, data: Partial<LocalTarget>) => {
const site = sites.find((site) => site.siteId === data.siteId); setTargets((prevTargets) => {
return prevTargets.map((target) => const site = sites.find((site) => site.siteId === data.siteId);
target.targetId === targetId return prevTargets.map((target) =>
? { target.targetId === targetId
...target, ? {
...data, ...target,
updated: true, ...data,
siteType: site ? site.type : target.siteType updated: true,
} siteType: site ? site.type : target.siteType
: target }
); : target
}); );
}, [sites]); });
},
[sites]
);
const openHealthCheckDialog = useCallback((target: LocalTarget) => { const openHealthCheckDialog = useCallback((target: LocalTarget) => {
setSelectedTargetForHealthCheck(target); setSelectedTargetForHealthCheck(target);
@@ -250,7 +268,6 @@ function ProxyResourceTargetsForm({
}, []); }, []);
const columns = useMemo((): ColumnDef<LocalTarget>[] => { const columns = useMemo((): ColumnDef<LocalTarget>[] => {
const priorityColumn: ColumnDef<LocalTarget> = { const priorityColumn: ColumnDef<LocalTarget> = {
id: "priority", id: "priority",
header: () => ( header: () => (
@@ -581,7 +598,17 @@ function ProxyResourceTargetsForm({
actionsColumn actionsColumn
]; ];
} }
}, [isAdvancedMode, isHttp, sites, updateTarget, getDockerStateForSite, refreshContainersForSite, openHealthCheckDialog, removeTarget, t]); }, [
isAdvancedMode,
isHttp,
sites,
updateTarget,
getDockerStateForSite,
refreshContainersForSite,
openHealthCheckDialog,
removeTarget,
t
]);
function addNewTarget() { function addNewTarget() {
const isHttp = resource.http; const isHttp = resource.http;

View File

@@ -20,7 +20,7 @@ import {
import { toast } from "@app/hooks/useToast"; import { toast } from "@app/hooks/useToast";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { useEffect, useState } from "react"; import { useState, useMemo } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
import CopyTextBox from "@app/components/CopyTextBox"; import CopyTextBox from "@app/components/CopyTextBox";
@@ -39,7 +39,8 @@ import { formatAxiosError } from "@app/lib/api";
import { cn } from "@app/lib/cn"; import { cn } from "@app/lib/cn";
import { createApiClient } from "@app/lib/api"; import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import { ListResourcesResponse } from "@server/routers/resource"; import { useQuery } from "@tanstack/react-query";
import { orgQueries } from "@app/lib/queries";
import { import {
Popover, Popover,
PopoverContent, PopoverContent,
@@ -94,14 +95,22 @@ export default function CreateShareLinkForm({
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const t = useTranslations(); const t = useTranslations();
const [resources, setResources] = useState< const { data: allResources = [] } = useQuery(
{ orgQueries.resources({ orgId: org?.org.orgId ?? "" })
resourceId: number; );
name: string;
niceId: string; const resources = useMemo(
resourceUrl: string; () =>
}[] allResources
>([]); .filter((r) => r.http)
.map((r) => ({
resourceId: r.resourceId,
name: r.name,
niceId: r.niceId,
resourceUrl: `${r.ssl ? "https://" : "http://"}${toUnicode(r.fullDomain || "")}/`
})),
[allResources]
);
const formSchema = z.object({ const formSchema = z.object({
resourceId: z.number({ message: t("shareErrorSelectResource") }), resourceId: z.number({ message: t("shareErrorSelectResource") }),
@@ -130,47 +139,6 @@ export default function CreateShareLinkForm({
} }
}); });
useEffect(() => {
if (!open) {
return;
}
async function fetchResources() {
const res = await api
.get<
AxiosResponse<ListResourcesResponse>
>(`/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,
niceId: r.niceId,
resourceUrl: `${r.ssl ? "https://" : "http://"}${toUnicode(r.fullDomain || "")}/`
}))
);
}
}
fetchResources();
}, [open]);
async function onSubmit(values: z.infer<typeof formSchema>) { async function onSubmit(values: z.infer<typeof formSchema>) {
setLoading(true); setLoading(true);

View File

@@ -1189,137 +1189,151 @@ export function InternalResourceForm({
{/* SSH Access tab */} {/* SSH Access tab */}
{!disableEnterpriseFeatures && mode !== "cidr" && ( {!disableEnterpriseFeatures && mode !== "cidr" && (
<div className="space-y-4 mt-4"> <div className="space-y-4 mt-4">
<PaidFeaturesAlert tiers={tierMatrix.sshPam} /> <PaidFeaturesAlert tiers={tierMatrix.sshPam} />
<div className="mb-8"> <div className="mb-8">
<label className="font-medium block"> <label className="font-medium block">
{t("internalResourceAuthDaemonStrategy")} {t("internalResourceAuthDaemonStrategy")}
</label> </label>
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
{t.rich( {t.rich(
"internalResourceAuthDaemonDescription", "internalResourceAuthDaemonDescription",
{ {
docsLink: (chunks) => ( docsLink: (chunks) => (
<a <a
href={ href={
"https://docs.pangolin.net/manage/ssh#setup-choose-your-architecture" "https://docs.pangolin.net/manage/ssh#setup-choose-your-architecture"
} }
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className={ className={
"text-primary inline-flex items-center gap-1" "text-primary inline-flex items-center gap-1"
} }
> >
{chunks} {chunks}
<ExternalLink className="size-3.5 shrink-0" /> <ExternalLink className="size-3.5 shrink-0" />
</a> </a>
) )
} }
)} )}
</div>
</div> </div>
</div> <div className="space-y-4">
<div className="space-y-4">
<FormField
control={form.control}
name="authDaemonMode"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"internalResourceAuthDaemonStrategyLabel"
)}
</FormLabel>
<FormControl>
<StrategySelect<"site" | "remote">
value={field.value ?? undefined}
options={[
{
id: "site",
title: t(
"internalResourceAuthDaemonSite"
),
description: t(
"internalResourceAuthDaemonSiteDescription"
),
disabled: sshSectionDisabled
},
{
id: "remote",
title: t(
"internalResourceAuthDaemonRemote"
),
description: t(
"internalResourceAuthDaemonRemoteDescription"
),
disabled: sshSectionDisabled
}
]}
onChange={(v) => {
if (sshSectionDisabled) return;
field.onChange(v);
if (v === "site") {
form.setValue(
"authDaemonPort",
null
);
}
}}
cols={2}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{authDaemonMode === "remote" && (
<FormField <FormField
control={form.control} control={form.control}
name="authDaemonPort" name="authDaemonMode"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel> <FormLabel>
{t( {t(
"internalResourceAuthDaemonPort" "internalResourceAuthDaemonStrategyLabel"
)} )}
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input <StrategySelect<
type="number" "site" | "remote"
min={1} >
max={65535} value={
placeholder="22123" field.value ?? undefined
{...field} }
disabled={sshSectionDisabled} options={[
value={field.value ?? ""} {
onChange={(e) => { id: "site",
if (sshSectionDisabled) return; title: t(
const v = "internalResourceAuthDaemonSite"
e.target.value; ),
if (v === "") { description: t(
field.onChange( "internalResourceAuthDaemonSiteDescription"
),
disabled:
sshSectionDisabled
},
{
id: "remote",
title: t(
"internalResourceAuthDaemonRemote"
),
description: t(
"internalResourceAuthDaemonRemoteDescription"
),
disabled:
sshSectionDisabled
}
]}
onChange={(v) => {
if (sshSectionDisabled)
return;
field.onChange(v);
if (v === "site") {
form.setValue(
"authDaemonPort",
null null
); );
return;
} }
const num = parseInt(
v,
10
);
field.onChange(
Number.isNaN(num)
? null
: num
);
}} }}
cols={2}
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
)} {authDaemonMode === "remote" && (
<FormField
control={form.control}
name="authDaemonPort"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"internalResourceAuthDaemonPort"
)}
</FormLabel>
<FormControl>
<Input
type="number"
min={1}
max={65535}
placeholder="22123"
{...field}
disabled={
sshSectionDisabled
}
value={
field.value ?? ""
}
onChange={(e) => {
if (
sshSectionDisabled
)
return;
const v =
e.target.value;
if (v === "") {
field.onChange(
null
);
return;
}
const num =
parseInt(v, 10);
field.onChange(
Number.isNaN(
num
)
? null
: num
);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
</div>
</div> </div>
</div>
)} )}
</HorizontalTabs> </HorizontalTabs>
</form> </form>

View File

@@ -69,15 +69,16 @@ export function LayoutMobileMenu({
<SheetDescription className="sr-only"> <SheetDescription className="sr-only">
{t("navbarDescription")} {t("navbarDescription")}
</SheetDescription> </SheetDescription>
<div className="flex-1 overflow-y-auto relative"> <div className="w-full border-b border-border">
<div className="px-1"> <div className="px-1 shrink-0">
<OrgSelector <OrgSelector
orgId={orgId} orgId={orgId}
orgs={orgs} orgs={orgs}
/> />
</div> </div>
<div className="w-full border-b border-border" /> </div>
<div className="px-3 pt-3"> <div className="flex-1 overflow-y-auto relative">
<div className="px-3">
{!isAdminPage && {!isAdminPage &&
user.serverAdmin && ( user.serverAdmin && (
<div className="mb-1"> <div className="mb-1">

View File

@@ -31,7 +31,6 @@ function getActionsCategories(root: boolean) {
[t("actionListInvitations")]: "listInvitations", [t("actionListInvitations")]: "listInvitations",
[t("actionRemoveUser")]: "removeUser", [t("actionRemoveUser")]: "removeUser",
[t("actionListUsers")]: "listUsers", [t("actionListUsers")]: "listUsers",
[t("actionListOrgDomains")]: "listOrgDomains",
[t("updateOrgUser")]: "updateOrgUser", [t("updateOrgUser")]: "updateOrgUser",
[t("createOrgUser")]: "createOrgUser", [t("createOrgUser")]: "createOrgUser",
[t("actionApplyBlueprint")]: "applyBlueprint", [t("actionApplyBlueprint")]: "applyBlueprint",
@@ -39,6 +38,16 @@ function getActionsCategories(root: boolean) {
[t("actionGetBlueprint")]: "getBlueprint" [t("actionGetBlueprint")]: "getBlueprint"
}, },
Domain: {
[t("actionListOrgDomains")]: "listOrgDomains",
[t("actionGetDomain")]: "getDomain",
[t("actionCreateOrgDomain")]: "createOrgDomain",
[t("actionUpdateOrgDomain")]: "updateOrgDomain",
[t("actionDeleteOrgDomain")]: "deleteOrgDomain",
[t("actionGetDNSRecords")]: "getDNSRecords",
[t("actionRestartOrgDomain")]: "restartOrgDomain"
},
Site: { Site: {
[t("actionCreateSite")]: "createSite", [t("actionCreateSite")]: "createSite",
[t("actionDeleteSite")]: "deleteSite", [t("actionDeleteSite")]: "deleteSite",

View File

@@ -4,7 +4,8 @@ import type { ListClientsResponse } from "@server/routers/client";
import type { ListDomainsResponse } from "@server/routers/domain"; import type { ListDomainsResponse } from "@server/routers/domain";
import type { import type {
GetResourceWhitelistResponse, GetResourceWhitelistResponse,
ListResourceNamesResponse ListResourceNamesResponse,
ListResourcesResponse
} from "@server/routers/resource"; } from "@server/routers/resource";
import type { ListRolesResponse } from "@server/routers/role"; import type { ListRolesResponse } from "@server/routers/role";
import type { ListSitesResponse } from "@server/routers/site"; import type { ListSitesResponse } from "@server/routers/site";
@@ -90,23 +91,13 @@ export const productUpdatesQueries = {
}) })
}; };
export const clientFilterSchema = z.object({
pageSize: z.int().prefault(1000).optional()
});
export const orgQueries = { export const orgQueries = {
clients: ({ clients: ({ orgId }: { orgId: string }) =>
orgId,
filters
}: {
orgId: string;
filters?: z.infer<typeof clientFilterSchema>;
}) =>
queryOptions({ queryOptions({
queryKey: ["ORG", orgId, "CLIENTS", filters] as const, queryKey: ["ORG", orgId, "CLIENTS"] as const,
queryFn: async ({ signal, meta }) => { queryFn: async ({ signal, meta }) => {
const sp = new URLSearchParams({ const sp = new URLSearchParams({
pageSize: (filters?.pageSize ?? 1000).toString() pageSize: "10000"
}); });
const res = await meta!.api.get< const res = await meta!.api.get<
@@ -143,9 +134,13 @@ export const orgQueries = {
queryOptions({ queryOptions({
queryKey: ["ORG", orgId, "SITES"] as const, queryKey: ["ORG", orgId, "SITES"] as const,
queryFn: async ({ signal, meta }) => { queryFn: async ({ signal, meta }) => {
const sp = new URLSearchParams({
pageSize: "10000"
});
const res = await meta!.api.get< const res = await meta!.api.get<
AxiosResponse<ListSitesResponse> AxiosResponse<ListSitesResponse>
>(`/org/${orgId}/sites`, { signal }); >(`/org/${orgId}/sites?${sp.toString()}`, { signal });
return res.data.data.sites; return res.data.data.sites;
} }
}), }),
@@ -182,6 +177,22 @@ export const orgQueries = {
); );
return res.data.data.idps; return res.data.data.idps;
} }
}),
resources: ({ orgId }: { orgId: string }) =>
queryOptions({
queryKey: ["ORG", orgId, "RESOURCES"] as const,
queryFn: async ({ signal, meta }) => {
const sp = new URLSearchParams({
pageSize: "10000"
});
const res = await meta!.api.get<
AxiosResponse<ListResourcesResponse>
>(`/org/${orgId}/resources?${sp.toString()}`, { signal });
return res.data.data.resources;
}
}) })
}; };