Compare commits

...

4 Commits

Author SHA1 Message Date
miloschwartz 407ba567a0 various visual changes 2026-06-08 22:07:53 -07:00
Owen f28571629f Make sure the pamMode is push for host resources 2026-06-08 21:54:06 -07:00
Owen 5a575c916b Handle backward compatability 2026-06-08 21:11:57 -07:00
Owen 9a7e534b10 Ssh session closed card 2026-06-08 17:44:48 -07:00
14 changed files with 171 additions and 144 deletions
+29 -3
View File
@@ -79,7 +79,10 @@ import logger from "@server/logger";
import { decrypt } from "@server/lib/crypto";
import config from "@server/lib/config";
import { exchangeSession } from "@server/routers/badger";
import { validateResourceSessionToken } from "@server/auth/sessions/resource";
import {
ResourceSessionValidationResult,
validateResourceSessionToken
} from "@server/auth/sessions/resource";
import { checkExitNodeOrg, resolveExitNodes } from "#private/lib/exitNodes";
import { maxmindLookup } from "@server/db/maxmind";
import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken";
@@ -1754,11 +1757,34 @@ hybridRouter.post(
resourceId
);
// this is for backward compatibility with nodes that did not have the policy id checking
const modifiedResult: ResourceSessionValidationResult = {
...result,
resourceSession: result.resourceSession
? {
...result.resourceSession,
// Prefer policy IDs, but keep legacy IDs populated for older nodes.
pincodeId:
result.resourceSession.policyPincodeId ??
result.resourceSession.pincodeId ??
null,
passwordId:
result.resourceSession.policyPasswordId ??
result.resourceSession.passwordId ??
null,
whitelistId:
result.resourceSession.policyWhitelistId ??
result.resourceSession.whitelistId ??
null
}
: null
};
return response(res, {
data: result,
data: modifiedResult,
success: true,
error: false,
message: result.resourceSession
message: modifiedResult.resourceSession
? "Resource session token is valid"
: "Resource session token is invalid or expired",
status: HttpCode.OK
+6 -4
View File
@@ -20,7 +20,8 @@ import {
ResourcePolicyPincode,
ResourcePolicyPassword,
ResourcePolicyHeaderAuth,
ResourceRule
ResourceRule,
ResourceSession
} from "@server/db";
import config from "@server/lib/config";
import { isIpInCidr, stripPortFromHost } from "@server/lib/ip";
@@ -536,7 +537,8 @@ export async function verifyResourceSession(
if (resourceSessionToken) {
const sessionCacheKey = `session:${resourceSessionToken}`;
let resourceSession: any = localCache.get(sessionCacheKey);
let resourceSession: ResourceSession | null | undefined =
localCache.get(sessionCacheKey);
if (!resourceSession) {
const result = await validateResourceSessionToken(
@@ -671,7 +673,7 @@ export async function verifyResourceSession(
orgId: resource.orgId,
location: ipCC,
apiKey: {
name: resourceSession.accessTokenTitle,
name: null,
apiKeyId: resourceSession.accessTokenId
}
},
@@ -717,7 +719,7 @@ export async function verifyResourceSession(
location: ipCC,
user: {
username: allowedUserData.username,
userId: resourceSession.userId
userId: allowedUserData.userId
}
},
parsedBody.data
+5
View File
@@ -197,6 +197,11 @@ export default async function migration() {
await db.execute(
sql`ALTER TABLE "siteResources" ADD COLUMN "pamMode" varchar(32) DEFAULT 'passthrough';`
);
await db.execute(sql`
UPDATE "siteResources"
SET "pamMode" = 'push'
WHERE LOWER(COALESCE("mode", '')) = 'host';
`);
await db.execute(
sql`ALTER TABLE "sites" ADD COLUMN "autoUpdateEnabled" boolean DEFAULT false NOT NULL;`
);
+7
View File
@@ -247,6 +247,13 @@ export default async function migration() {
ALTER TABLE 'siteResources' ADD COLUMN 'pamMode' text DEFAULT 'passthrough';
`
).run();
db.prepare(
`
UPDATE 'siteResources'
SET "pamMode" = 'push'
WHERE LOWER(COALESCE("mode", '')) = 'host';
`
).run();
db.prepare(
`
@@ -108,10 +108,7 @@ export default async function ClientResourcesPage(
siteNiceId: siteResource.siteNiceIds[idx],
online: siteResource.siteOnlines[idx]
})),
mode:
siteResource.pamMode && siteResource.mode === "host"
? "ssh"
: siteResource.mode,
mode: siteResource.mode,
scheme: siteResource.scheme,
ssl: siteResource.ssl,
siteNames: siteResource.siteNames,
@@ -41,6 +41,7 @@ import {
FormMessage
} from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
import { Label } from "@app/components/ui/label";
import {
Popover,
PopoverContent,
@@ -1172,52 +1173,55 @@ export default function Page() {
{isNative ? (
<SettingsFormCell span="half">
<Popover
open={
nativeSiteOpen
}
onOpenChange={
setNativeSiteOpen
}
>
<PopoverTrigger
asChild
<div className="grid gap-2">
<Label>{t("sites")}</Label>
<Popover
open={
nativeSiteOpen
}
onOpenChange={
setNativeSiteOpen
}
>
<Button
variant="outline"
role="combobox"
className="w-full justify-between font-normal"
<PopoverTrigger
asChild
>
<span className="truncate">
{nativeSelectedSite?.name ??
t(
"siteSelect"
)}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
<SitesSelector
orgId={
orgId as string
}
selectedSite={
nativeSelectedSite
}
onSelectSite={(
site
) => {
setNativeSelectedSite(
<Button
variant="outline"
role="combobox"
className="w-full justify-between font-normal"
>
<span className="truncate">
{nativeSelectedSite?.name ??
t(
"siteSelect"
)}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
<SitesSelector
orgId={
orgId as string
}
selectedSite={
nativeSelectedSite
}
onSelectSite={(
site
);
setNativeSiteOpen(
false
);
}}
/>
</PopoverContent>
</Popover>
) => {
setNativeSelectedSite(
site
);
setNativeSiteOpen(
false
);
}}
/>
</PopoverContent>
</Popover>
</div>
</SettingsFormCell>
) : standardDaemonLocation !==
"site" ||
+50 -4
View File
@@ -122,6 +122,9 @@ export default function SshClient({
const [connected, setConnected] = useState(false);
const [connecting, setConnecting] = useState(false);
const [connectError, setConnectError] = useState<string | null>(null);
const [sessionClosedCode, setSessionClosedCode] = useState<number | null>(
null
);
const terminalRef = useRef<HTMLDivElement>(null);
const xtermRef = useRef<import("@xterm/xterm").Terminal | null>(null);
@@ -222,6 +225,8 @@ export default function SshClient({
authMethod: AuthTab = "password"
) {
setConnecting(true);
setSessionClosedCode(null);
setConnectError(null);
if (!target) {
setConnectError(t("sshErrorNoTarget"));
@@ -257,8 +262,10 @@ export default function SshClient({
let authConfirmed = false;
let authErrorShown = false;
let socketOpened = false;
ws.onopen = () => {
socketOpened = true;
ws.send(
JSON.stringify({
type: "auth",
@@ -331,13 +338,18 @@ export default function SshClient({
};
ws.onclose = (evt) => {
wsRef.current = null;
setConnecting(false);
const isCleanClose = evt.wasClean || evt.code === 1000;
if (isCleanClose && (authConfirmed || socketOpened)) {
xtermRef.current?.dispose();
xtermRef.current = null;
setConnected(false);
setSessionClosedCode(evt.code);
return;
}
if (authConfirmed) {
setConnected(false);
if (evt.wasClean || evt.code === 1000) {
window.close();
return;
}
xtermRef.current?.writeln(
`\r\n\x1b[33m${t("sshConnectionClosedCode", { code: evt.code })}\x1b[0m\r\n`
);
@@ -457,6 +469,40 @@ export default function SshClient({
);
}
if (sessionClosedCode !== null) {
return (
<BrandedAuthSurface primaryColor={primaryColor}>
<PoweredByPangolin />
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>{t("sshTitle")}</CardTitle>
<CardDescription>
{t("sshConnectionClosedCode", {
code: sessionClosedCode
})}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Alert>
<AlertDescription>
This session has ended. You can close this tab
now.
</AlertDescription>
</Alert>
<Button
type="button"
className="w-full"
onClick={() => window.close()}
>
{t("close")}
</Button>
</CardContent>
</Card>
<AuthPageFooterNotices />
</BrandedAuthSurface>
);
}
return (
<>
{!connected && (
+1 -1
View File
@@ -91,7 +91,7 @@ export default function AuthPageBrandingForm({
orgSubtitle: branding?.orgSubtitle ?? `Log in to {{orgName}}`,
resourceTitle:
branding?.resourceTitle ??
`Authenticate to access {{resourceName}}`,
`Authenticate to Access {{resourceName}}`,
resourceSubtitle:
branding?.resourceSubtitle ??
`Choose your preferred authentication method for {{resourceName}}`,
@@ -12,11 +12,9 @@ import {
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useOrgContext } from "@app/hooks/useOrgContext";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
import { orgQueries } from "@app/lib/queries";
import { zodResolver } from "@hookform/resolvers/zod";
import { build } from "@server/build";
import { UserType } from "@server/types/UserTypes";
import { useQuery } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import {
@@ -67,12 +65,6 @@ export function CreatePolicyForm({}: CreatePolicyFormProps) {
env.server.maxmind_asn_path && env.server.maxmind_asn_path.length > 0
);
const { data: orgRoles = [], isLoading: isLoadingOrgRoles } = useQuery(
orgQueries.roles({ orgId: org.org.orgId })
);
const { data: orgUsers = [], isLoading: isLoadingOrgUsers } = useQuery(
orgQueries.users({ orgId: org.org.orgId })
);
const { data: orgIdps = [], isLoading: isLoadingOrgIdps } = useQuery(
orgQueries.identityProviders({
orgId: org.org.orgId,
@@ -163,26 +155,6 @@ export function CreatePolicyForm({}: CreatePolicyFormProps) {
}
}
const allRoles = useMemo(
() =>
orgRoles
.map((role) => ({
id: role.roleId.toString(),
text: role.name
}))
.filter((role) => role.text !== "Admin"),
[orgRoles]
);
const allUsers = useMemo(
() =>
orgUsers.map((user) => ({
id: user.id.toString(),
text: `${getUserDisplayName({ email: user.email, username: user.username })}${user.type !== UserType.Internal ? ` (${user.idpName})` : ""}`
})),
[orgUsers]
);
const allIdps = useMemo(() => {
if (build === "saas") {
if (isPaidUser(tierMatrix.orgOidc)) {
@@ -197,7 +169,7 @@ export function CreatePolicyForm({}: CreatePolicyFormProps) {
return [];
}, [orgIdps, isPaidUser]);
if (isLoadingOrgRoles || isLoadingOrgUsers || isLoadingOrgIdps) {
if (isLoadingOrgIdps) {
return <></>;
}
@@ -252,8 +224,6 @@ export function CreatePolicyForm({}: CreatePolicyFormProps) {
form={form}
orgId={org.org.orgId}
allIdps={allIdps}
allRoles={allRoles}
allUsers={allUsers}
emailEnabled={env.email.emailEnabled}
/>
<PolicyAccessRulesSection
@@ -19,8 +19,6 @@ type PolicyAuthStackSectionCreateProps = {
form: UseFormReturn<PolicyFormValues, any, any>;
orgId: string;
allIdps: { id: number; text: string }[];
allRoles: { id: string; text: string }[];
allUsers: { id: string; text: string }[];
emailEnabled: boolean;
};
@@ -12,7 +12,8 @@ import {
SettingsSubsectionTitle,
SettingsSectionTitle
} from "@app/components/Settings";
import { TagInput } from "@app/components/tags/tag-input";
import { RolesSelector } from "@app/components/roles-selector";
import { UsersSelector } from "@app/components/users-selector";
import { FormField } from "@app/components/ui/form";
import { useTranslations } from "next-intl";
import { useState } from "react";
@@ -38,27 +39,18 @@ export type PolicyAuthStackSectionCreateProps = {
form: UseFormReturn<PolicyFormValues, any, any>;
orgId: string;
allIdps: { id: number; text: string }[];
allRoles: { id: string; text: string }[];
allUsers: { id: string; text: string }[];
emailEnabled: boolean;
};
export function PolicyAuthStackSectionCreate({
form: parentForm,
orgId,
allIdps,
allRoles,
allUsers,
emailEnabled
}: PolicyAuthStackSectionCreateProps) {
const t = useTranslations();
const [editingMethod, setEditingMethod] =
useState<PolicyAuthMethodId | null>(null);
const [activeRolesTagIndex, setActiveRolesTagIndex] = useState<
number | null
>(null);
const [activeUsersTagIndex, setActiveUsersTagIndex] = useState<
number | null
>(null);
const sso = useWatch({ control: parentForm.control, name: "sso" });
const skipToIdpId = useWatch({
@@ -126,47 +118,38 @@ export function PolicyAuthStackSectionCreate({
}
allIdps={allIdps}
rolesEditor={
<FormField<PolicyFormValues, "roles">
<FormField
control={parentForm.control}
name="roles"
render={({ field }) => (
<TagInput
{...field}
activeTagIndex={activeRolesTagIndex}
setActiveTagIndex={
setActiveRolesTagIndex
<RolesSelector
orgId={orgId}
selectedRoles={field.value}
onSelectRoles={(selected) =>
parentForm.setValue(
"roles",
selected
)
}
placeholder={t("accessRoleSelect2")}
tags={field.value ?? []}
setTags={(newRoles) =>
field.onChange(newRoles)
}
autocompleteOptions={allRoles}
allowDuplicates={false}
size="sm"
restrictAdminRole
/>
)}
/>
}
usersEditor={
<FormField<PolicyFormValues, "users">
<FormField
control={parentForm.control}
name="users"
render={({ field }) => (
<TagInput
{...field}
activeTagIndex={activeUsersTagIndex}
setActiveTagIndex={
setActiveUsersTagIndex
<UsersSelector
orgId={orgId}
selectedUsers={field.value}
onSelectUsers={(selected) =>
parentForm.setValue(
"users",
selected
)
}
placeholder={t("accessUserSelect")}
tags={field.value ?? []}
setTags={(newUsers) =>
field.onChange(newUsers)
}
autocompleteOptions={allUsers}
allowDuplicates={false}
size="sm"
/>
)}
/>
+1 -1
View File
@@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@app/lib/cn";
const buttonVariants = cva(
"cursor-pointer inline-flex items-center justify-center whitespace-nowrap text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-0 disabled:pointer-events-none disabled:opacity-50",
"cursor-pointer inline-flex items-center justify-center whitespace-nowrap text-sm font-normal ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-0 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
@@ -351,10 +351,6 @@ export function ControlledDataTable<TData, TValue>({
dataTableFilterDropdownContentClassName
}
>
<DropdownMenuLabel>
{filter.label}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{filter.options.map(
(option) => {
const isChecked =
@@ -484,13 +480,6 @@ export function ControlledDataTable<TData, TValue>({
align="end"
className="w-48"
>
<DropdownMenuLabel>
{t(
"toggleColumns"
) ||
"Toggle columns"}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{table
.getAllColumns()
.filter(
+1 -1
View File
@@ -91,7 +91,7 @@ const TableHead = React.forwardRef<
<th
ref={ref}
className={cn(
"h-10 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
"h-10 text-left align-middle font-medium text-muted-foreground [&_button]:font-medium [&:has([role=checkbox])]:pr-0",
className
)}
{...props}