mirror of
https://github.com/fosrl/pangolin.git
synced 2026-07-23 14:04:12 +02:00
Merge branch 'dev' into refactor/batch-status-requests
This commit is contained in:
@@ -248,6 +248,39 @@ export async function loginProxy(
|
||||
return await makeApiRequest<LoginResponse>(url, "POST", request);
|
||||
}
|
||||
|
||||
export async function logoutProxy(): Promise<ResponseT<null>> {
|
||||
const env = pullEnv();
|
||||
const serverPort = process.env.SERVER_EXTERNAL_PORT;
|
||||
const url = `http://localhost:${serverPort}/api/v1/auth/logout`;
|
||||
|
||||
const result = await makeApiRequest<null>(url, "POST");
|
||||
|
||||
try {
|
||||
const headersList = await reqHeaders();
|
||||
const host = headersList.get("host")?.split(":")[0];
|
||||
const allCookies = await cookies();
|
||||
const clearOptions = {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: "lax" as const,
|
||||
path: "/",
|
||||
maxAge: 0
|
||||
};
|
||||
// Clear both host-only and domain-scoped variants.
|
||||
allCookies.set(env.server.sessionCookieName, "", clearOptions);
|
||||
if (host) {
|
||||
allCookies.set(env.server.sessionCookieName, "", {
|
||||
...clearOptions,
|
||||
domain: host
|
||||
});
|
||||
}
|
||||
} catch (cookieError) {
|
||||
console.error("Failed to clear session cookie:", cookieError);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function securityKeyStartProxy(
|
||||
request: SecurityKeyStartRequest,
|
||||
forceLogin?: boolean
|
||||
|
||||
@@ -29,7 +29,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useActionState, useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { PrivateResourceAccessFields } from "../../PrivateResourceAccessFields";
|
||||
import { PrivateResourceAccessFields } from "@app/components/PrivateResourceAccessFields";
|
||||
|
||||
export default function PrivateResourceAccessPage() {
|
||||
const t = useTranslations();
|
||||
|
||||
@@ -20,12 +20,12 @@ import { useTranslations } from "next-intl";
|
||||
import { useActionState, useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { PrivateResourceSitesField } from "../../PrivateResourceSitesField";
|
||||
import { PrivateResourceCidrDestinationField } from "../../PrivateResourceDestinationFields";
|
||||
import { PrivateResourcePortRanges } from "../../PrivateResourcePortRanges";
|
||||
import { buildSelectedSitesForResource } from "../../privateResourceUtils";
|
||||
import { asAnyControl, asAnySetValue } from "../../formControlUtils";
|
||||
import { useSaveSiteResource } from "../../useSaveSiteResource";
|
||||
import { PrivateResourceSitesField } from "@app/components/PrivateResourceSitesField";
|
||||
import { PrivateResourceCidrDestinationField } from "@app/components/PrivateResourceDestinationFields";
|
||||
import { PrivateResourcePortRanges } from "@app/components/PrivateResourcePortRanges";
|
||||
import { useSaveSiteResource } from "@app/hooks/useSaveSiteResource";
|
||||
import { asAnyControl, asAnySetValue } from "@app/lib/formControlUtils";
|
||||
import { buildSelectedSitesForResource } from "@app/lib/privateResourceUtils";
|
||||
|
||||
export default function PrivateResourceCidrPage() {
|
||||
const t = useTranslations();
|
||||
|
||||
@@ -16,19 +16,21 @@ import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { SwitchInput } from "@app/components/SwitchInput";
|
||||
import { createGeneralFormSchema } from "@app/lib/privateResourceForm";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useActionState, useMemo } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { useSaveSiteResource } from "../../useSaveSiteResource";
|
||||
import { useSaveSiteResource } from "@app/hooks/useSaveSiteResource";
|
||||
|
||||
export default function PrivateResourceGeneralPage() {
|
||||
const t = useTranslations();
|
||||
@@ -41,7 +43,8 @@ export default function PrivateResourceGeneralPage() {
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
name: siteResource.name,
|
||||
niceId: siteResource.niceId
|
||||
niceId: siteResource.niceId,
|
||||
enabled: siteResource.enabled
|
||||
}
|
||||
});
|
||||
|
||||
@@ -52,7 +55,8 @@ export default function PrivateResourceGeneralPage() {
|
||||
const data = form.getValues();
|
||||
await save({
|
||||
name: data.name,
|
||||
niceId: data.niceId
|
||||
niceId: data.niceId,
|
||||
enabled: data.enabled
|
||||
});
|
||||
}, null);
|
||||
|
||||
@@ -76,6 +80,42 @@ export default function PrivateResourceGeneralPage() {
|
||||
id="private-resource-general-form"
|
||||
>
|
||||
<SettingsFormGrid>
|
||||
<SettingsFormCell span="full">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enabled"
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<SwitchInput
|
||||
id="enable-resource"
|
||||
defaultChecked={
|
||||
siteResource.enabled
|
||||
}
|
||||
label={t(
|
||||
"resourceEnable"
|
||||
)}
|
||||
onCheckedChange={(
|
||||
val
|
||||
) =>
|
||||
form.setValue(
|
||||
"enabled",
|
||||
val
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"disabledResourceDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
|
||||
<SettingsFormCell span="half">
|
||||
<FormField
|
||||
control={form.control}
|
||||
|
||||
@@ -20,16 +20,16 @@ import { useTranslations } from "next-intl";
|
||||
import { useActionState, useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { PrivateResourceSitesField } from "../../PrivateResourceSitesField";
|
||||
import { PrivateResourceHostDestinationFields } from "../../PrivateResourceDestinationFields";
|
||||
import { PrivateResourcePortRanges } from "../../PrivateResourcePortRanges";
|
||||
import { buildSelectedSitesForResource } from "../../privateResourceUtils";
|
||||
import { PrivateResourceSitesField } from "@app/components/PrivateResourceSitesField";
|
||||
import { PrivateResourceHostDestinationFields } from "@app/components/PrivateResourceDestinationFields";
|
||||
import { PrivateResourcePortRanges } from "@app/components/PrivateResourcePortRanges";
|
||||
import { useSaveSiteResource } from "@app/hooks/useSaveSiteResource";
|
||||
import {
|
||||
asAnyControl,
|
||||
asAnySetValue,
|
||||
asAnyWatch
|
||||
} from "../../formControlUtils";
|
||||
import { useSaveSiteResource } from "../../useSaveSiteResource";
|
||||
} from "@app/lib/formControlUtils";
|
||||
import { buildSelectedSitesForResource } from "@app/lib/privateResourceUtils";
|
||||
|
||||
export default function PrivateResourceHostPage() {
|
||||
const t = useTranslations();
|
||||
|
||||
@@ -22,15 +22,15 @@ import { useTranslations } from "next-intl";
|
||||
import { useActionState, useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { PrivateResourceSitesField } from "../../PrivateResourceSitesField";
|
||||
import { PrivateResourceHttpFields } from "../../PrivateResourceHttpFields";
|
||||
import { buildSelectedSitesForResource } from "../../privateResourceUtils";
|
||||
import { PrivateResourceSitesField } from "@app/components/PrivateResourceSitesField";
|
||||
import { PrivateResourceHttpFields } from "@app/components/PrivateResourceHttpFields";
|
||||
import { useSaveSiteResource } from "@app/hooks/useSaveSiteResource";
|
||||
import {
|
||||
asAnyControl,
|
||||
asAnySetValue,
|
||||
asAnyWatch
|
||||
} from "../../formControlUtils";
|
||||
import { useSaveSiteResource } from "../../useSaveSiteResource";
|
||||
} from "@app/lib/formControlUtils";
|
||||
import { buildSelectedSitesForResource } from "@app/lib/privateResourceUtils";
|
||||
|
||||
export default function PrivateResourceHttpPage() {
|
||||
const t = useTranslations();
|
||||
|
||||
@@ -26,15 +26,15 @@ import { useTranslations } from "next-intl";
|
||||
import { useActionState, useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { PrivateResourceSshFields } from "../../PrivateResourceSshFields";
|
||||
import { buildSelectedSitesForResource } from "../../privateResourceUtils";
|
||||
import { PrivateResourceSshFields } from "@app/components/PrivateResourceSshFields";
|
||||
import type { Selectedsite } from "@app/components/site-selector";
|
||||
import { useSaveSiteResource } from "@app/hooks/useSaveSiteResource";
|
||||
import {
|
||||
asAnyControl,
|
||||
asAnySetValue,
|
||||
asAnyWatch
|
||||
} from "../../formControlUtils";
|
||||
import { useSaveSiteResource } from "../../useSaveSiteResource";
|
||||
import type { Selectedsite } from "@app/components/site-selector";
|
||||
} from "@app/lib/formControlUtils";
|
||||
import { buildSelectedSitesForResource } from "@app/lib/privateResourceUtils";
|
||||
|
||||
export default function PrivateResourceSshPage() {
|
||||
const t = useTranslations();
|
||||
|
||||
@@ -50,16 +50,20 @@ import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useMemo, useState, useTransition } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { PrivateResourceSitesField } from "../PrivateResourceSitesField";
|
||||
import { PrivateResourceHttpFields } from "../PrivateResourceHttpFields";
|
||||
import { PrivateResourceSshFields } from "../PrivateResourceSshFields";
|
||||
import { PrivateResourcePortRanges } from "../PrivateResourcePortRanges";
|
||||
import { PrivateResourceSitesField } from "@app/components/PrivateResourceSitesField";
|
||||
import { PrivateResourceHttpFields } from "@app/components/PrivateResourceHttpFields";
|
||||
import { PrivateResourceSshFields } from "@app/components/PrivateResourceSshFields";
|
||||
import { PrivateResourcePortRanges } from "@app/components/PrivateResourcePortRanges";
|
||||
import {
|
||||
PrivateResourceAliasField,
|
||||
PrivateResourceCidrDestinationField,
|
||||
PrivateResourceHostDestinationFields
|
||||
} from "../PrivateResourceDestinationFields";
|
||||
import { asAnyControl, asAnySetValue, asAnyWatch } from "../formControlUtils";
|
||||
} from "@app/components/PrivateResourceDestinationFields";
|
||||
import {
|
||||
asAnyControl,
|
||||
asAnySetValue,
|
||||
asAnyWatch
|
||||
} from "@app/lib/formControlUtils";
|
||||
|
||||
export default function CreatePrivateResourcePage() {
|
||||
const params = useParams();
|
||||
|
||||
@@ -27,6 +27,7 @@ export default async function ClientResourcesPage(
|
||||
const params = await props.params;
|
||||
const t = await getTranslations();
|
||||
const searchParams = new URLSearchParams(await props.searchParams);
|
||||
searchParams.set("status", "approved");
|
||||
|
||||
let siteResources: ListAllSiteResourcesByOrgResponse["siteResources"] = [];
|
||||
let pagination: ListAllSiteResourcesByOrgResponse["pagination"] = {
|
||||
|
||||
@@ -38,6 +38,7 @@ export default async function ProxyResourcesPage(
|
||||
const params = await props.params;
|
||||
const t = await getTranslations();
|
||||
const searchParams = new URLSearchParams(await props.searchParams);
|
||||
searchParams.set("status", "approved");
|
||||
|
||||
let resources: ListResourcesResponse["resources"] = [];
|
||||
let pagination: ListResourcesResponse["pagination"] = {
|
||||
|
||||
@@ -16,8 +16,11 @@ import LoginCardHeader from "@app/components/LoginCardHeader";
|
||||
import { priv } from "@app/lib/api";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { LoginFormIDP } from "@app/components/LoginForm";
|
||||
import { ListIdpsResponse } from "@server/routers/idp";
|
||||
import { ListIdpsResponse, type GetIdpResponse } from "@server/routers/idp";
|
||||
import type { Metadata } from "next";
|
||||
import { cookies } from "next/headers";
|
||||
import { LAST_USED_IDP_COOKIE_NAME } from "@app/lib/consts";
|
||||
import z from "zod";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Log In"
|
||||
@@ -29,8 +32,9 @@ export default async function Page(props: {
|
||||
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||
}) {
|
||||
const searchParams = await props.searchParams;
|
||||
const getUser = cache(verifySession);
|
||||
const user = await getUser({ skipCheckVerifyEmail: true });
|
||||
const user = await verifySession({ skipCheckVerifyEmail: true });
|
||||
|
||||
const lastUsedIdpCookie = (await cookies()).get(LAST_USED_IDP_COOKIE_NAME);
|
||||
|
||||
const isInvite = searchParams?.redirect?.includes("/invite");
|
||||
const forceLoginParam = searchParams?.forceLogin;
|
||||
@@ -85,19 +89,47 @@ export default async function Page(props: {
|
||||
(build === "enterprise" && env.app.identityProviderMode === "org");
|
||||
|
||||
let loginIdps: LoginFormIDP[] = [];
|
||||
let lastUsedIdpForSmartLogin: (LoginFormIDP & { orgId?: string }) | null =
|
||||
null;
|
||||
if (!useSmartLogin) {
|
||||
// Load IdPs for DashboardLoginForm (OSS or org-only IdP mode)
|
||||
if (build === "oss" || env.app.identityProviderMode !== "org") {
|
||||
const idpsRes = await cache(
|
||||
async () =>
|
||||
await priv.get<AxiosResponse<ListIdpsResponse>>("/idp")
|
||||
)();
|
||||
const idpsRes =
|
||||
await priv.get<AxiosResponse<ListIdpsResponse>>("/idp");
|
||||
loginIdps = idpsRes.data.data.idps.map((idp) => ({
|
||||
idpId: idp.idpId,
|
||||
name: idp.name,
|
||||
variant: idp.type
|
||||
})) as LoginFormIDP[];
|
||||
}
|
||||
} else {
|
||||
if (lastUsedIdpCookie) {
|
||||
const lastUsedIdpSchema = z.object({
|
||||
orgId: z.string().optional(),
|
||||
idpId: z.number()
|
||||
});
|
||||
try {
|
||||
const persistedData = lastUsedIdpSchema.parse(
|
||||
JSON.parse(lastUsedIdpCookie.value)
|
||||
);
|
||||
|
||||
const idpRes = await priv.get<AxiosResponse<GetIdpResponse>>(
|
||||
`/idp/${persistedData.idpId}`
|
||||
);
|
||||
|
||||
const idp = idpRes.data.data.idp;
|
||||
|
||||
lastUsedIdpForSmartLogin = {
|
||||
idpId: idp.idpId,
|
||||
name: idp.name,
|
||||
variant: idp.type,
|
||||
orgId: persistedData.orgId,
|
||||
lastUsed: true
|
||||
};
|
||||
} catch (error) {
|
||||
// the idp might not exist or the data is malformatted, skip this
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const t = await getTranslations();
|
||||
@@ -160,6 +192,7 @@ export default async function Page(props: {
|
||||
redirect={redirectUrl}
|
||||
forceLogin={forceLogin}
|
||||
defaultUser={defaultUser}
|
||||
lastUsedIdp={lastUsedIdpForSmartLogin}
|
||||
orgSignIn={
|
||||
!isInvite &&
|
||||
(build === "saas" ||
|
||||
|
||||
@@ -13,6 +13,8 @@ import { redirect } from "next/navigation";
|
||||
import OrgLoginPage from "@app/components/OrgLoginPage";
|
||||
import { pullEnv } from "@app/lib/pullEnv";
|
||||
import type { Metadata } from "next";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import { isOrgSubscribed } from "@app/lib/api/isOrgSubscribed";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Organization Login"
|
||||
@@ -68,15 +70,22 @@ export default async function OrgAuthPage(props: {
|
||||
variant: idp.variant
|
||||
})) as LoginFormIDP[];
|
||||
|
||||
const hasLoginPageBranding = await isOrgSubscribed(
|
||||
orgId,
|
||||
tierMatrix.loginPageBranding
|
||||
);
|
||||
|
||||
let branding: LoadLoginPageBrandingResponse | null = null;
|
||||
try {
|
||||
const res = await priv.get<
|
||||
AxiosResponse<LoadLoginPageBrandingResponse>
|
||||
>(`/login-page-branding?orgId=${orgId}`);
|
||||
if (res.status === 200) {
|
||||
branding = res.data.data;
|
||||
}
|
||||
} catch (error) {}
|
||||
if (hasLoginPageBranding) {
|
||||
try {
|
||||
const res = await priv.get<
|
||||
AxiosResponse<LoadLoginPageBrandingResponse>
|
||||
>(`/login-page-branding?orgId=${orgId}`);
|
||||
if (res.status === 200) {
|
||||
branding = res.data.data;
|
||||
}
|
||||
} catch (error) {}
|
||||
}
|
||||
|
||||
return (
|
||||
<OrgLoginPage
|
||||
|
||||
@@ -19,6 +19,7 @@ import { isOrgSubscribed } from "@app/lib/api/isOrgSubscribed";
|
||||
import { OrgSelectionForm } from "@app/components/OrgSelectionForm";
|
||||
import OrgLoginPage from "@app/components/OrgLoginPage";
|
||||
import type { Metadata } from "next";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Choose Organization"
|
||||
@@ -83,7 +84,10 @@ export default async function OrgAuthPage(props: {
|
||||
redirect(env.app.dashboardUrl);
|
||||
}
|
||||
|
||||
const subscribed = await isOrgSubscribed(loginPage.orgId);
|
||||
const subscribed = await isOrgSubscribed(
|
||||
loginPage.orgId,
|
||||
tierMatrix.loginPageDomain
|
||||
);
|
||||
|
||||
if (build === "saas" && !subscribed) {
|
||||
console.log(
|
||||
|
||||
@@ -27,6 +27,7 @@ import { CheckOrgUserAccessResponse } from "@server/routers/org";
|
||||
import OrgPolicyRequired from "@app/components/OrgPolicyRequired";
|
||||
import { isOrgSubscribed } from "@app/lib/api/isOrgSubscribed";
|
||||
import { normalizePostAuthPath } from "@server/lib/normalizePostAuthPath";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -70,14 +71,25 @@ export default async function ResourceAuthPage(props: {
|
||||
);
|
||||
}
|
||||
|
||||
const subscribed = await isOrgSubscribed(authInfo.orgId);
|
||||
const hasLoginPageDomain = await isOrgSubscribed(
|
||||
authInfo.orgId,
|
||||
tierMatrix.loginPageDomain
|
||||
);
|
||||
const hasOrgOidc = await isOrgSubscribed(
|
||||
authInfo.orgId,
|
||||
tierMatrix.orgOidc
|
||||
);
|
||||
const hasLoginPageBranding = await isOrgSubscribed(
|
||||
authInfo.orgId,
|
||||
tierMatrix.loginPageBranding
|
||||
);
|
||||
|
||||
const allHeaders = await headers();
|
||||
const host = allHeaders.get("host");
|
||||
|
||||
const expectedHost = env.app.dashboardUrl.split("//")[1];
|
||||
if (host !== expectedHost) {
|
||||
if (build === "saas" && !subscribed) {
|
||||
if (build === "saas" && !hasLoginPageDomain) {
|
||||
redirect(env.app.dashboardUrl);
|
||||
}
|
||||
|
||||
@@ -106,7 +118,10 @@ export default async function ResourceAuthPage(props: {
|
||||
const redirectPort = new URL(searchParams.redirect).port;
|
||||
const serverResourceHostWithPort = `${serverResourceHost}:${redirectPort}`;
|
||||
|
||||
const wildcardMatchesRedirect = (wildcardDomain: string, host: string): boolean => {
|
||||
const wildcardMatchesRedirect = (
|
||||
wildcardDomain: string,
|
||||
host: string
|
||||
): boolean => {
|
||||
if (!wildcardDomain.startsWith("*.")) return false;
|
||||
const suffix = wildcardDomain.slice(1); // e.g. ".wildcard.owen.fosrl.io"
|
||||
return host.endsWith(suffix) && host.length > suffix.length;
|
||||
@@ -228,7 +243,7 @@ export default async function ResourceAuthPage(props: {
|
||||
|
||||
let loginIdps: LoginFormIDP[] = [];
|
||||
if (build === "saas" || env.app.identityProviderMode === "org") {
|
||||
if (subscribed) {
|
||||
if (hasOrgOidc) {
|
||||
const idpsRes = await cache(
|
||||
async () =>
|
||||
await priv.get<AxiosResponse<ListOrgIdpsResponse>>(
|
||||
@@ -271,7 +286,7 @@ export default async function ResourceAuthPage(props: {
|
||||
|
||||
let branding: LoadLoginPageBrandingResponse | null = null;
|
||||
try {
|
||||
if (subscribed) {
|
||||
if (hasLoginPageBranding) {
|
||||
const res = await priv.get<
|
||||
AxiosResponse<LoadLoginPageBrandingResponse>
|
||||
>(`/login-page-branding?orgId=${authInfo.orgId}`);
|
||||
|
||||
+1
-4
@@ -5,7 +5,6 @@ import UserProvider from "@app/providers/UserProvider";
|
||||
import { ListUserOrgsResponse } from "@server/routers/org";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { redirect } from "next/navigation";
|
||||
import { cache } from "react";
|
||||
import OrganizationLanding from "@app/components/OrganizationLanding";
|
||||
import { pullEnv } from "@app/lib/pullEnv";
|
||||
import { cleanRedirect } from "@app/lib/cleanRedirect";
|
||||
@@ -13,7 +12,6 @@ import { Layout } from "@app/components/Layout";
|
||||
import RedirectToOrg from "@app/components/RedirectToOrg";
|
||||
import { InitialSetupCompleteResponse } from "@server/routers/auth";
|
||||
import { cookies } from "next/headers";
|
||||
import { build } from "@server/build";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -29,8 +27,7 @@ export default async function Page(props: {
|
||||
|
||||
const env = pullEnv();
|
||||
|
||||
const getUser = cache(verifySession);
|
||||
const user = await getUser({ skipCheckVerifyEmail: true });
|
||||
const user = await verifySession({ skipCheckVerifyEmail: true });
|
||||
|
||||
let complete = false;
|
||||
try {
|
||||
|
||||
@@ -52,7 +52,6 @@ import { 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,
|
||||
@@ -63,11 +62,26 @@ import AccessTokenSection from "@app/components/AccessTokenUsage";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toUnicode } from "punycode";
|
||||
import { ResourceSelector, type SelectedResource } from "./resource-selector";
|
||||
import { UserSelector, type SelectedUser } from "@app/components/user-selector";
|
||||
|
||||
type CreatedShareLink = {
|
||||
accessTokenId: string;
|
||||
resourceId: number;
|
||||
resourceName: string;
|
||||
resourceNiceId: string;
|
||||
title: string | null;
|
||||
createdAt: number;
|
||||
expiresAt: number | null;
|
||||
userId?: string | null;
|
||||
userName?: string | null;
|
||||
username?: string | null;
|
||||
userEmail?: string | null;
|
||||
};
|
||||
|
||||
type FormProps = {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
onCreated?: (result: ShareLinkRow) => void;
|
||||
onCreated?: (result: CreatedShareLink) => void;
|
||||
};
|
||||
|
||||
export default function CreateShareLinkForm({
|
||||
@@ -85,6 +99,8 @@ export default function CreateShareLinkForm({
|
||||
const [accessToken, setAccessToken] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [neverExpire, setNeverExpire] = useState(false);
|
||||
const [persistSession, setPersistSession] = useState(false);
|
||||
const [selectedUser, setSelectedUser] = useState<SelectedUser | null>(null);
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const t = useTranslations();
|
||||
@@ -175,7 +191,9 @@ export default function CreateShareLinkForm({
|
||||
values.resourceName ||
|
||||
"Resource" + values.resourceId
|
||||
}),
|
||||
path: values.path
|
||||
path: values.path,
|
||||
persistSession,
|
||||
userId: selectedUser?.id
|
||||
}
|
||||
)
|
||||
.catch((e) => {
|
||||
@@ -205,7 +223,11 @@ export default function CreateShareLinkForm({
|
||||
resourceNiceId: selectedResource ? selectedResource.niceId : "",
|
||||
title: token.title,
|
||||
createdAt: token.createdAt,
|
||||
expiresAt: token.expiresAt
|
||||
expiresAt: token.expiresAt,
|
||||
userId: token.userId,
|
||||
userName: selectedUser?.text ?? null,
|
||||
username: null,
|
||||
userEmail: null
|
||||
});
|
||||
}
|
||||
|
||||
@@ -220,6 +242,9 @@ export default function CreateShareLinkForm({
|
||||
setOpen(val);
|
||||
setLink(null);
|
||||
setLoading(false);
|
||||
setNeverExpire(false);
|
||||
setPersistSession(false);
|
||||
setSelectedUser(null);
|
||||
form.reset();
|
||||
}}
|
||||
>
|
||||
@@ -344,6 +369,48 @@ export default function CreateShareLinkForm({
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<FormLabel>
|
||||
{t(
|
||||
"shareAssociateUserOptional"
|
||||
)}
|
||||
</FormLabel>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
"w-full justify-between",
|
||||
!selectedUser &&
|
||||
"text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{selectedUser?.text
|
||||
? selectedUser.text
|
||||
: t("userSelect")}
|
||||
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0 w-[var(--radix-popover-trigger-width)]">
|
||||
<UserSelector
|
||||
orgId={org.org.orgId}
|
||||
selectedUser={
|
||||
selectedUser
|
||||
}
|
||||
onSelectUser={
|
||||
setSelectedUser
|
||||
}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"shareAssociateUserDescription"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<FormLabel>
|
||||
@@ -437,6 +504,34 @@ export default function CreateShareLinkForm({
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start space-x-2">
|
||||
<Checkbox
|
||||
id="persist-session"
|
||||
checked={persistSession}
|
||||
onCheckedChange={(val) =>
|
||||
setPersistSession(
|
||||
val as boolean
|
||||
)
|
||||
}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<label
|
||||
htmlFor="persist-session"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
{t(
|
||||
"sharePersistSession"
|
||||
)}
|
||||
</label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"sharePersistSessionDescription"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("shareExpireDescription")}
|
||||
</p>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
@@ -43,10 +43,12 @@ import {
|
||||
CheckCircle2,
|
||||
ChevronsUpDown,
|
||||
ExternalLink,
|
||||
Globe,
|
||||
KeyRound,
|
||||
Zap
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||
import { usePaidStatus } from "@/hooks/usePaidStatus";
|
||||
import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
@@ -494,6 +496,28 @@ export default function DomainPicker({
|
||||
const hasMoreProvided =
|
||||
sortedAvailableOptions.length > providedDomainsShown;
|
||||
|
||||
const noDomainsAvailable =
|
||||
!loadingDomains &&
|
||||
organizationDomains.length === 0 &&
|
||||
(build === "oss" || hideFreeDomain || requiresPaywall);
|
||||
|
||||
if (noDomainsAvailable) {
|
||||
return (
|
||||
<Alert>
|
||||
<Globe className="h-4 w-4" />
|
||||
<AlertTitle>{t("domainPickerNoDomainsAvailableTitle")}</AlertTitle>
|
||||
<AlertDescription className="space-y-3">
|
||||
<p>{t("domainPickerNoDomainsAvailableDescription")}</p>
|
||||
<Button asChild size="sm" variant="outline">
|
||||
<Link href={`/${orgId}/settings/domains`}>
|
||||
{t("domainPickerNoDomainsAvailableAction")}
|
||||
</Link>
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
|
||||
@@ -1,26 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { Alert, AlertDescription } from "@app/components/ui/alert";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { generateOidcUrlProxy } from "@app/actions/server";
|
||||
import IdpTypeIcon from "@app/components/IdpTypeIcon";
|
||||
import {
|
||||
generateOidcUrlProxy,
|
||||
type GenerateOidcUrlResponse
|
||||
} from "@app/actions/server";
|
||||
import { Alert, AlertDescription } from "@app/components/ui/alert";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { cleanRedirect } from "@app/lib/cleanRedirect";
|
||||
import { LAST_USED_IDP_COOKIE_NAME } from "@app/lib/consts";
|
||||
import { setClientCookie } from "@app/lib/setClientCookie";
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
redirect as redirectTo,
|
||||
useParams,
|
||||
useRouter,
|
||||
useSearchParams
|
||||
} from "next/navigation";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { cleanRedirect } from "@app/lib/cleanRedirect";
|
||||
import { useEffect, useState, useTransition } from "react";
|
||||
|
||||
export type LoginFormIDP = {
|
||||
idpId: number;
|
||||
name: string;
|
||||
variant?: string;
|
||||
lastUsed?: boolean;
|
||||
};
|
||||
|
||||
type IdpLoginButtonsProps = {
|
||||
@@ -35,7 +34,6 @@ export default function IdpLoginButtons({
|
||||
orgId
|
||||
}: IdpLoginButtonsProps) {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const t = useTranslations();
|
||||
|
||||
const params = useSearchParams();
|
||||
@@ -52,10 +50,22 @@ export default function IdpLoginButtons({
|
||||
}
|
||||
}, []);
|
||||
|
||||
const [loading, startTransition] = useTransition();
|
||||
|
||||
async function loginWithIdp(idpId: number) {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
setClientCookie(
|
||||
LAST_USED_IDP_COOKIE_NAME,
|
||||
JSON.stringify({
|
||||
orgId,
|
||||
idpId
|
||||
}),
|
||||
{
|
||||
sameSite: "Lax"
|
||||
}
|
||||
);
|
||||
|
||||
let redirectToUrl: string | undefined;
|
||||
try {
|
||||
console.log("generating", idpId, redirect || "/", orgId);
|
||||
@@ -68,7 +78,6 @@ export default function IdpLoginButtons({
|
||||
|
||||
if (response.error) {
|
||||
setError(response.message);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -84,7 +93,6 @@ export default function IdpLoginButtons({
|
||||
"An unexpected error occurred. Please try again."
|
||||
})
|
||||
);
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
if (redirectToUrl) {
|
||||
@@ -124,20 +132,38 @@ export default function IdpLoginButtons({
|
||||
idp.variant || idp.name.toLowerCase();
|
||||
|
||||
return (
|
||||
<Button
|
||||
<div
|
||||
className="w-full relative"
|
||||
key={idp.idpId}
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full inline-flex items-center space-x-2"
|
||||
onClick={() => {
|
||||
loginWithIdp(idp.idpId);
|
||||
}}
|
||||
disabled={loading}
|
||||
loading={loading}
|
||||
>
|
||||
<IdpTypeIcon type={effectiveType} size={16} />
|
||||
<span>{idp.name}</span>
|
||||
</Button>
|
||||
<Button
|
||||
key={idp.idpId}
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full inline-flex items-center space-x-2 after:absolute after:inset-0 after:z-10"
|
||||
onClick={() => {
|
||||
startTransition(() =>
|
||||
loginWithIdp(idp.idpId)
|
||||
);
|
||||
}}
|
||||
disabled={loading}
|
||||
loading={loading}
|
||||
>
|
||||
<IdpTypeIcon
|
||||
type={effectiveType}
|
||||
size={16}
|
||||
/>
|
||||
<span>{idp.name}</span>
|
||||
</Button>
|
||||
|
||||
{idp.lastUsed && (
|
||||
<div className="absolute inset-0">
|
||||
<span className="absolute top-0 right-0 text-xs bg-primary text-primary-foreground rounded-bl-sm rounded-tr-sm px-2 py-0.5">
|
||||
{t("idpLastUsed")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
|
||||
@@ -93,14 +93,20 @@ export default function InviteStatusCard({
|
||||
setType(type);
|
||||
|
||||
if (!user && type === "user_does_not_exist") {
|
||||
const inviteRedirect = encodeURIComponent(
|
||||
`/invite?token=${tokenParam}`
|
||||
);
|
||||
const redirectUrl = email
|
||||
? `/auth/signup?redirect=/invite?token=${tokenParam}&email=${email}`
|
||||
: `/auth/signup?redirect=/invite?token=${tokenParam}`;
|
||||
? `/auth/signup?redirect=${inviteRedirect}&email=${encodeURIComponent(email)}`
|
||||
: `/auth/signup?redirect=${inviteRedirect}`;
|
||||
router.push(redirectUrl);
|
||||
} else if (!user && type === "not_logged_in") {
|
||||
const inviteRedirect = encodeURIComponent(
|
||||
`/invite?token=${tokenParam}`
|
||||
);
|
||||
const redirectUrl = email
|
||||
? `/auth/login?redirect=/invite?token=${tokenParam}&user=${email}`
|
||||
: `/auth/login?redirect=/invite?token=${tokenParam}`;
|
||||
? `/auth/login?redirect=${inviteRedirect}&user=${encodeURIComponent(email)}`
|
||||
: `/auth/login?redirect=${inviteRedirect}`;
|
||||
router.push(redirectUrl);
|
||||
} else {
|
||||
setLoading(false);
|
||||
@@ -112,17 +118,23 @@ export default function InviteStatusCard({
|
||||
|
||||
async function goToLogin() {
|
||||
await api.post("/auth/logout", {});
|
||||
const inviteRedirect = encodeURIComponent(
|
||||
`/invite?token=${tokenParam}`
|
||||
);
|
||||
const redirectUrl = email
|
||||
? `/auth/login?redirect=/invite?token=${tokenParam}&user=${email}`
|
||||
: `/auth/login?redirect=/invite?token=${tokenParam}`;
|
||||
? `/auth/login?redirect=${inviteRedirect}&user=${encodeURIComponent(email)}`
|
||||
: `/auth/login?redirect=${inviteRedirect}`;
|
||||
router.push(redirectUrl);
|
||||
}
|
||||
|
||||
async function goToSignup() {
|
||||
await api.post("/auth/logout", {});
|
||||
const inviteRedirect = encodeURIComponent(
|
||||
`/invite?token=${tokenParam}`
|
||||
);
|
||||
const redirectUrl = email
|
||||
? `/auth/signup?redirect=/invite?token=${tokenParam}&email=${email}`
|
||||
: `/auth/signup?redirect=/invite?token=${tokenParam}`;
|
||||
? `/auth/signup?redirect=${inviteRedirect}&email=${encodeURIComponent(email)}`
|
||||
: `/auth/signup?redirect=${inviteRedirect}`;
|
||||
router.push(redirectUrl);
|
||||
}
|
||||
|
||||
|
||||
@@ -30,10 +30,7 @@ import Link from "next/link";
|
||||
import { GenerateOidcUrlResponse } from "@server/routers/idp";
|
||||
import { Separator } from "./ui/separator";
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
generateOidcUrlProxy,
|
||||
loginProxy
|
||||
} from "@app/actions/server";
|
||||
import { generateOidcUrlProxy, loginProxy } from "@app/actions/server";
|
||||
import { redirect as redirectTo } from "next/navigation";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import IdpTypeIcon from "@app/components/IdpTypeIcon";
|
||||
@@ -41,11 +38,13 @@ import IdpTypeIcon from "@app/components/IdpTypeIcon";
|
||||
import { loadReoScript } from "reodotdev";
|
||||
import { build } from "@server/build";
|
||||
import MfaInputForm from "@app/components/MfaInputForm";
|
||||
import { useLocalStorage } from "@app/hooks/useLocalStorage";
|
||||
|
||||
export type LoginFormIDP = {
|
||||
idpId: number;
|
||||
name: string;
|
||||
variant?: string;
|
||||
lastUsed?: boolean;
|
||||
};
|
||||
|
||||
type LoginFormProps = {
|
||||
@@ -105,7 +104,6 @@ export default function LoginForm({
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
const formSchema = z.object({
|
||||
email: z.string().email({ message: t("emailInvalid") }),
|
||||
password: z.string().min(8, { message: t("passwordRequirementsChars") })
|
||||
@@ -130,11 +128,16 @@ export default function LoginForm({
|
||||
}
|
||||
});
|
||||
|
||||
const [lastUsedIdpId, setLastUsedIdpId] = useLocalStorage<string | null>(
|
||||
"login:last-used-idp",
|
||||
null
|
||||
);
|
||||
|
||||
async function onSubmit(values: any) {
|
||||
const { email, password } = form.getValues();
|
||||
const { code } = mfaForm.getValues();
|
||||
|
||||
setLastUsedIdpId(null);
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
@@ -179,8 +182,7 @@ export default function LoginForm({
|
||||
if (data.useSecurityKey) {
|
||||
setError(
|
||||
t("securityKeyRequired", {
|
||||
defaultValue:
|
||||
"Please use your security key to sign in."
|
||||
defaultValue: "Please use your security key to sign in."
|
||||
})
|
||||
);
|
||||
return;
|
||||
@@ -242,6 +244,8 @@ export default function LoginForm({
|
||||
|
||||
async function loginWithIdp(idpId: number) {
|
||||
let redirectUrl: string | undefined;
|
||||
|
||||
setLastUsedIdpId(idpId.toString());
|
||||
try {
|
||||
const data = await generateOidcUrlProxy(
|
||||
idpId,
|
||||
@@ -356,7 +360,6 @@ export default function LoginForm({
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
|
||||
{!mfaRequested && (
|
||||
<>
|
||||
<SecurityKeyAuthButton
|
||||
@@ -385,25 +388,41 @@ export default function LoginForm({
|
||||
idp.variant || idp.name.toLowerCase();
|
||||
|
||||
return (
|
||||
<Button
|
||||
<div
|
||||
className="w-full relative"
|
||||
key={idp.idpId}
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full inline-flex items-center space-x-2"
|
||||
onClick={() => {
|
||||
loginWithIdp(idp.idpId);
|
||||
}}
|
||||
>
|
||||
<IdpTypeIcon type={effectiveType} size={16} />
|
||||
<span>{idp.name}</span>
|
||||
</Button>
|
||||
<Button
|
||||
key={idp.idpId}
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full inline-flex items-center space-x-2 after:absolute after:inset-0 after:z-10"
|
||||
onClick={() => {
|
||||
loginWithIdp(idp.idpId);
|
||||
}}
|
||||
>
|
||||
<IdpTypeIcon
|
||||
type={effectiveType}
|
||||
size={16}
|
||||
/>
|
||||
<span>{idp.name}</span>
|
||||
</Button>
|
||||
|
||||
{lastUsedIdpId ===
|
||||
idp.idpId.toString() && (
|
||||
<div className="absolute inset-0">
|
||||
<span className="absolute top-0 right-0 text-xs bg-primary text-primary-foreground rounded-bl-sm rounded-tr-sm px-2 py-0.5">
|
||||
{t("idpLastUsed")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -22,6 +22,8 @@ import Link from "next/link";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { cleanRedirect } from "@app/lib/cleanRedirect";
|
||||
import MfaInputForm from "@app/components/MfaInputForm";
|
||||
import { LAST_USED_IDP_COOKIE_NAME } from "@app/lib/consts";
|
||||
import { setClientCookie } from "@app/lib/setClientCookie";
|
||||
|
||||
type LoginPasswordFormProps = {
|
||||
identifier: string;
|
||||
@@ -82,6 +84,12 @@ export default function LoginPasswordForm({
|
||||
const { password } = values;
|
||||
const { code } = mfaForm.getValues();
|
||||
|
||||
// delete last used auth cookie by setting it in the past
|
||||
setClientCookie(LAST_USED_IDP_COOKIE_NAME, JSON.stringify(null), {
|
||||
sameSite: "Lax",
|
||||
days: -1
|
||||
});
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
|
||||
@@ -12,8 +12,8 @@ import { Shield, ArrowRight } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useState } from "react";
|
||||
import { logoutProxy } from "@app/actions/server";
|
||||
|
||||
type OrgPolicyRequiredProps = {
|
||||
orgId: string;
|
||||
@@ -40,21 +40,23 @@ export default function OrgPolicyRequired({
|
||||
}: OrgPolicyRequiredProps) {
|
||||
const t = useTranslations();
|
||||
const router = useRouter();
|
||||
|
||||
const api = createApiClient(useEnvContext());
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const sessionExpired =
|
||||
policies?.maxSessionLength &&
|
||||
policies.maxSessionLength.compliant === false;
|
||||
|
||||
function reauthenticate() {
|
||||
api.post("/auth/logout")
|
||||
.catch(() => {})
|
||||
.then(() => {
|
||||
const destination = redirectAfterAuth ?? `/${orgId}`;
|
||||
router.push(destination);
|
||||
router.refresh();
|
||||
});
|
||||
async function reauthenticate() {
|
||||
setLoading(true);
|
||||
try {
|
||||
await logoutProxy();
|
||||
} catch (error) {
|
||||
console.error("Error during logout:", error);
|
||||
} finally {
|
||||
const destination = redirectAfterAuth ?? `/${orgId}`;
|
||||
router.push(destination);
|
||||
router.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
if (sessionExpired) {
|
||||
@@ -76,6 +78,7 @@ export default function OrgPolicyRequired({
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={reauthenticate}
|
||||
loading={loading}
|
||||
>
|
||||
{t("reauthenticate")}
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||
import {
|
||||
Credenza,
|
||||
CredenzaBody,
|
||||
CredenzaContent,
|
||||
CredenzaDescription,
|
||||
CredenzaFooter,
|
||||
CredenzaHeader,
|
||||
CredenzaTitle
|
||||
} from "@app/components/Credenza";
|
||||
import SiteResourcesOverview from "@app/components/SiteResourcesOverview";
|
||||
import { Badge } from "@app/components/ui/badge";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
@@ -24,6 +34,7 @@ import {
|
||||
ArrowUp10Icon,
|
||||
ArrowUpRight,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronsUpDownIcon,
|
||||
MoreHorizontal,
|
||||
X
|
||||
@@ -65,8 +76,10 @@ export default function PendingSitesTable({
|
||||
const [isRefreshing, startTransition] = useTransition();
|
||||
const [approvingIds, setApprovingIds] = useState<Set<number>>(new Set());
|
||||
const [rejectingIds, setRejectingIds] = useState<Set<number>>(new Set());
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [isRejectModalOpen, setIsRejectModalOpen] = useState(false);
|
||||
const [selectedSite, setSelectedSite] = useState<SiteRow | null>(null);
|
||||
const [resourcesDialogSite, setResourcesDialogSite] =
|
||||
useState<SiteRow | null>(null);
|
||||
|
||||
const api = createApiClient(useEnvContext());
|
||||
const t = useTranslations();
|
||||
@@ -111,7 +124,7 @@ export default function PendingSitesTable({
|
||||
async function approveSite(siteId: number) {
|
||||
setApprovingIds((prev) => new Set(prev).add(siteId));
|
||||
try {
|
||||
await api.post(`/site/${siteId}`, { status: "approved" });
|
||||
await api.post(`/site/${siteId}/approve`);
|
||||
toast({
|
||||
title: t("success"),
|
||||
description: t("siteApproveSuccess"),
|
||||
@@ -136,20 +149,20 @@ export default function PendingSitesTable({
|
||||
async function rejectSite(siteId: number) {
|
||||
setRejectingIds((prev) => new Set(prev).add(siteId));
|
||||
try {
|
||||
await api.delete(`/site/${siteId}`);
|
||||
await api.post(`/site/${siteId}/reject`);
|
||||
toast({
|
||||
title: t("success"),
|
||||
description: t("siteDeleted"),
|
||||
description: t("siteRejectSuccess"),
|
||||
variant: "default"
|
||||
});
|
||||
setIsDeleteModalOpen(false);
|
||||
setIsRejectModalOpen(false);
|
||||
setSelectedSite(null);
|
||||
router.refresh();
|
||||
} catch (e) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("siteErrorDelete"),
|
||||
description: formatAxiosError(e, t("siteErrorDelete"))
|
||||
title: t("siteRejectError"),
|
||||
description: formatAxiosError(e, t("siteRejectError"))
|
||||
});
|
||||
} finally {
|
||||
setRejectingIds((prev) => {
|
||||
@@ -342,6 +355,29 @@ export default function PendingSitesTable({
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "resources",
|
||||
accessorKey: "resourceCount",
|
||||
friendlyName: t("resources"),
|
||||
header: () => <span className="p-3">{t("resources")}</span>,
|
||||
cell: ({ row }) => {
|
||||
const siteRow = row.original;
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setResourcesDialogSite(siteRow)}
|
||||
className="flex h-8 items-center gap-2 px-0 font-normal"
|
||||
>
|
||||
<span className="text-sm tabular-nums">
|
||||
{siteRow.resourceCount} {t("resources")}
|
||||
</span>
|
||||
<ChevronDown className="h-3 w-3 shrink-0" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "exitNode",
|
||||
friendlyName: t("exitNode"),
|
||||
@@ -445,7 +481,7 @@ export default function PendingSitesTable({
|
||||
disabled={isApproving || isRejecting}
|
||||
onClick={() => {
|
||||
setSelectedSite(siteRow);
|
||||
setIsDeleteModalOpen(true);
|
||||
setIsRejectModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<X className="mr-2 w-4 h-4" />
|
||||
@@ -491,25 +527,63 @@ export default function PendingSitesTable({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Credenza
|
||||
open={Boolean(resourcesDialogSite)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setResourcesDialogSite(null);
|
||||
}}
|
||||
>
|
||||
<CredenzaContent className="md:max-w-7xl">
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>{t("siteResourcesTab")}</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
{t("siteResourcesDialogDescription")}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
{resourcesDialogSite != null && (
|
||||
<SiteResourcesOverview
|
||||
orgIdOverride={orgId}
|
||||
siteId={resourcesDialogSite.id}
|
||||
initialPublicData={null}
|
||||
initialPrivateData={null}
|
||||
initialPublicForbidden={false}
|
||||
initialPrivateForbidden={false}
|
||||
showViewAllLinks={false}
|
||||
/>
|
||||
)}
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setResourcesDialogSite(null)}
|
||||
>
|
||||
{t("close")}
|
||||
</Button>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
</Credenza>
|
||||
|
||||
{selectedSite && (
|
||||
<ConfirmDeleteDialog
|
||||
open={isDeleteModalOpen}
|
||||
open={isRejectModalOpen}
|
||||
setOpen={(val) => {
|
||||
setIsDeleteModalOpen(val);
|
||||
setIsRejectModalOpen(val);
|
||||
if (!val) {
|
||||
setSelectedSite(null);
|
||||
}
|
||||
}}
|
||||
dialog={
|
||||
<div className="space-y-2">
|
||||
<p>{t("siteQuestionRemove")}</p>
|
||||
<p>{t("siteMessageRemove")}</p>
|
||||
<p>{t("siteQuestionReject")}</p>
|
||||
<p>{t("siteMessageReject")}</p>
|
||||
</div>
|
||||
}
|
||||
buttonText={t("siteConfirmDelete")}
|
||||
buttonText={t("siteConfirmReject")}
|
||||
onConfirm={async () => rejectSite(selectedSite.id)}
|
||||
string={selectedSite.name}
|
||||
title={t("siteDelete")}
|
||||
title={t("siteReject")}
|
||||
/>
|
||||
)}
|
||||
<ControlledDataTable
|
||||
|
||||
@@ -55,6 +55,7 @@ function getActionsCategories(root: boolean) {
|
||||
[t("actionGetSite")]: "getSite",
|
||||
[t("actionListSites")]: "listSites",
|
||||
[t("actionUpdateSite")]: "updateSite",
|
||||
[t("actionUpdateSiteApprovals")]: "updateSiteApprovals",
|
||||
[t("actionListSiteRoles")]: "listSiteRoles"
|
||||
},
|
||||
|
||||
@@ -78,7 +79,8 @@ function getActionsCategories(root: boolean) {
|
||||
[t("actionGetSiteResource")]: "getSiteResource",
|
||||
[t("actionListSiteResources")]: "listSiteResources",
|
||||
[t("actionUpdateSiteResource")]: "updateSiteResource",
|
||||
[t("actionCreateResourceSessionToken")]: "createResourceSessionToken"
|
||||
[t("actionCreateResourceSessionToken")]:
|
||||
"createResourceSessionToken"
|
||||
},
|
||||
|
||||
Target: {
|
||||
@@ -141,6 +143,13 @@ function getActionsCategories(root: boolean) {
|
||||
Logs: {
|
||||
[t("actionExportLogs")]: "exportLogs",
|
||||
[t("actionViewLogs")]: "viewLogs"
|
||||
},
|
||||
|
||||
"Site Provisioning Key": {
|
||||
[t("actionCreateSiteProvisioningKey")]: "createSiteProvisioningKey",
|
||||
[t("actionListSiteProvisioningKeys")]: "listSiteProvisioningKeys",
|
||||
[t("actionUpdateSiteProvisioningKey")]: "updateSiteProvisioningKey",
|
||||
[t("actionDeleteSiteProvisioningKey")]: "deleteSiteProvisioningKey"
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@ import {
|
||||
import { ChevronsUpDown } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import type { Control, FieldPath, FieldValues } from "react-hook-form";
|
||||
import { PrivateResourceMultiSiteRoutingHelp } from "./PrivateResourceMultiSiteRoutingHelp";
|
||||
import { PrivateResourceMultiSiteRoutingHelp } from "@app/components/PrivateResourceMultiSiteRoutingHelp";
|
||||
|
||||
type PrivateResourceSitesFieldProps<T extends FieldValues> = {
|
||||
control: Control<T>;
|
||||
+3
-3
@@ -9,10 +9,10 @@ import {
|
||||
} from "@app/components/Settings";
|
||||
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||
import { SshServerSettingsFields } from "@app/components/SshServerSettingsFields";
|
||||
import { PrivateResourceAliasField } from "./PrivateResourceDestinationFields";
|
||||
import { PrivateResourceSitesField } from "./PrivateResourceSitesField";
|
||||
import { getSshUseMultiSiteTargetForm } from "./privateResourceUtils";
|
||||
import { PrivateResourceAliasField } from "@app/components/PrivateResourceDestinationFields";
|
||||
import { PrivateResourceSitesField } from "@app/components/PrivateResourceSitesField";
|
||||
import { inferSshPamMode } from "@app/lib/privateResourceForm";
|
||||
import { getSshUseMultiSiteTargetForm } from "@app/lib/privateResourceUtils";
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
PopoverContent,
|
||||
PopoverTrigger
|
||||
} from "@app/components/ui/popover";
|
||||
import { Switch } from "@app/components/ui/switch";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useNavigationContext } from "@app/hooks/useNavigationContext";
|
||||
import { useOptimisticLabels } from "@app/hooks/useOptimisticLabels";
|
||||
@@ -36,7 +37,9 @@ import { getPrivateResourceSettingsHref } from "@app/lib/launcherResourceAdminHr
|
||||
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
|
||||
import { build } from "@server/build";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import { UpdateSiteResourceResponse } from "@server/routers/siteResource";
|
||||
import type { PaginationState } from "@tanstack/react-table";
|
||||
import { AxiosResponse } from "axios";
|
||||
import {
|
||||
ArrowDown01Icon,
|
||||
ArrowRight,
|
||||
@@ -49,8 +52,17 @@ import {
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { startTransition, useMemo, useState, useTransition } from "react";
|
||||
import {
|
||||
startTransition,
|
||||
useMemo,
|
||||
useOptimistic,
|
||||
useRef,
|
||||
useState,
|
||||
useTransition,
|
||||
type ComponentRef
|
||||
} from "react";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
import z from "zod";
|
||||
import { ColumnFilterButton } from "./ColumnFilterButton";
|
||||
import { LabelColumnFilterButton } from "./LabelColumnFilterButton";
|
||||
import { LabelsTableCell } from "./LabelsTableCell";
|
||||
@@ -114,6 +126,11 @@ function isSafeUrlForLink(href: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
const booleanSearchFilterSchema = z
|
||||
.enum(["true", "false"])
|
||||
.optional()
|
||||
.catch(undefined);
|
||||
|
||||
type ClientResourcesTableProps = {
|
||||
internalResources: InternalResourceRow[];
|
||||
orgId: string;
|
||||
@@ -174,6 +191,30 @@ export default function PrivateResourcesTable({
|
||||
});
|
||||
};
|
||||
|
||||
async function toggleInternalResourceEnabled(
|
||||
val: boolean,
|
||||
resourceId: number
|
||||
) {
|
||||
try {
|
||||
await api.post<AxiosResponse<UpdateSiteResourceResponse>>(
|
||||
`site-resource/${resourceId}`,
|
||||
{
|
||||
enabled: val
|
||||
}
|
||||
);
|
||||
router.refresh();
|
||||
} catch (e) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("resourcesErrorUpdate"),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
t("resourcesErrorUpdateDescription")
|
||||
)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const deleteInternalResource = async (
|
||||
resourceId: number,
|
||||
siteId: number
|
||||
@@ -429,6 +470,36 @@ export default function PrivateResourcesTable({
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "enabled",
|
||||
friendlyName: t("enabled"),
|
||||
header: () => (
|
||||
<ColumnFilterButton
|
||||
options={[
|
||||
{ value: "true", label: t("enabled") },
|
||||
{ value: "false", label: t("disabled") }
|
||||
]}
|
||||
selectedValue={booleanSearchFilterSchema.parse(
|
||||
searchParams.get("enabled")
|
||||
)}
|
||||
onValueChange={(value) =>
|
||||
handleFilterChange("enabled", value)
|
||||
}
|
||||
searchPlaceholder={t("searchPlaceholder")}
|
||||
emptyMessage={t("emptySearchOptions")}
|
||||
label={t("enabled")}
|
||||
className="p-3"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<InternalResourceEnabledForm
|
||||
resource={row.original}
|
||||
onToggleInternalResourceEnabled={
|
||||
toggleInternalResourceEnabled
|
||||
}
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: "labels",
|
||||
accessorKey: "labels",
|
||||
@@ -643,3 +714,39 @@ function ClientResourceLabelCell({
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type InternalResourceEnabledFormProps = {
|
||||
resource: InternalResourceRow;
|
||||
onToggleInternalResourceEnabled: (
|
||||
val: boolean,
|
||||
resourceId: number
|
||||
) => Promise<void>;
|
||||
};
|
||||
|
||||
function InternalResourceEnabledForm({
|
||||
resource,
|
||||
onToggleInternalResourceEnabled
|
||||
}: InternalResourceEnabledFormProps) {
|
||||
const [optimisticEnabled, setOptimisticEnabled] = useOptimistic(
|
||||
resource.enabled
|
||||
);
|
||||
|
||||
const formRef = useRef<ComponentRef<"form">>(null);
|
||||
|
||||
async function submitAction(formData: FormData) {
|
||||
const newEnabled = !(formData.get("enabled") === "on");
|
||||
setOptimisticEnabled(newEnabled);
|
||||
await onToggleInternalResourceEnabled(newEnabled, resource.id);
|
||||
}
|
||||
|
||||
return (
|
||||
<form action={submitAction} ref={formRef}>
|
||||
<Switch
|
||||
checked={optimisticEnabled}
|
||||
disabled={optimisticEnabled !== resource.enabled}
|
||||
name="enabled"
|
||||
onCheckedChange={() => formRef.current?.requestSubmit()}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ import moment from "moment";
|
||||
import CreateShareLinkForm from "@app/components/CreateShareLinkForm";
|
||||
import { constructShareLink } from "@app/lib/shareLinks";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
|
||||
|
||||
export type ShareLinkRow = {
|
||||
accessTokenId: string;
|
||||
@@ -44,6 +45,10 @@ export type ShareLinkRow = {
|
||||
title: string | null;
|
||||
createdAt: number;
|
||||
expiresAt: number | null;
|
||||
userId?: string | null;
|
||||
userName?: string | null;
|
||||
username?: string | null;
|
||||
userEmail?: string | null;
|
||||
};
|
||||
|
||||
type ShareLinksTableProps = {
|
||||
@@ -155,6 +160,41 @@ export default function ShareLinksTable({
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "userId",
|
||||
friendlyName: t("user"),
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
{t("user")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
if (!r.userId) {
|
||||
return <span>-</span>;
|
||||
}
|
||||
return (
|
||||
<Link href={`/${orgId}/settings/access/users/${r.userId}`}>
|
||||
<Button variant="outline" size="sm">
|
||||
{getUserDisplayName({
|
||||
email: r.userEmail,
|
||||
name: r.userName,
|
||||
username: r.username
|
||||
})}
|
||||
<ArrowUpRight className="ml-2 h-3 w-3" />
|
||||
</Button>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
},
|
||||
// {
|
||||
// accessorKey: "domain",
|
||||
// header: "Link",
|
||||
|
||||
@@ -174,8 +174,8 @@ type OverviewRow = {
|
||||
type OverviewColumnProps = {
|
||||
title: string;
|
||||
description: string;
|
||||
viewAllHref: string;
|
||||
viewAllLabel: string;
|
||||
viewAllHref?: string;
|
||||
viewAllLabel?: string;
|
||||
emptyLabel: string;
|
||||
isForbidden: boolean;
|
||||
isFetching: boolean;
|
||||
@@ -212,12 +212,14 @@ function OverviewColumn({
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href={viewAllHref}
|
||||
className="shrink-0 text-muted-foreground text-sm hover:underline"
|
||||
>
|
||||
{viewAllLabel}
|
||||
</Link>
|
||||
{viewAllHref && viewAllLabel ? (
|
||||
<Link
|
||||
href={viewAllHref}
|
||||
className="shrink-0 text-muted-foreground text-sm hover:underline"
|
||||
>
|
||||
{viewAllLabel}
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -319,6 +321,8 @@ type SiteResourcesOverviewProps = {
|
||||
initialPrivateForbidden: boolean;
|
||||
/** When not under `/[orgId]/...` routes, pass org id explicitly (e.g. credenza on sites list). */
|
||||
orgIdOverride?: string;
|
||||
/** When false, hides links to the org resources tables filtered by this site. */
|
||||
showViewAllLinks?: boolean;
|
||||
};
|
||||
|
||||
export default function SiteResourcesOverview({
|
||||
@@ -327,7 +331,8 @@ export default function SiteResourcesOverview({
|
||||
initialPrivateData,
|
||||
initialPublicForbidden,
|
||||
initialPrivateForbidden,
|
||||
orgIdOverride
|
||||
orgIdOverride,
|
||||
showViewAllLinks = true
|
||||
}: SiteResourcesOverviewProps) {
|
||||
const t = useTranslations();
|
||||
const params = useParams<{ orgId: string }>();
|
||||
@@ -467,8 +472,10 @@ export default function SiteResourcesOverview({
|
||||
key="public"
|
||||
title={t("siteResourcesSectionPublic")}
|
||||
description={t("siteResourcesSectionPublicDescription")}
|
||||
viewAllHref={publicViewAllHref}
|
||||
viewAllLabel={t("siteResourcesViewAllPublic")}
|
||||
viewAllHref={showViewAllLinks ? publicViewAllHref : undefined}
|
||||
viewAllLabel={
|
||||
showViewAllLinks ? t("siteResourcesViewAllPublic") : undefined
|
||||
}
|
||||
emptyLabel={t("siteResourcesEmptyPublic")}
|
||||
isForbidden={publicForbidden}
|
||||
isFetching={publicQuery.isFetching}
|
||||
@@ -484,8 +491,10 @@ export default function SiteResourcesOverview({
|
||||
key="private"
|
||||
title={t("siteResourcesSectionPrivate")}
|
||||
description={t("siteResourcesSectionPrivateDescription")}
|
||||
viewAllHref={privateViewAllHref}
|
||||
viewAllLabel={t("siteResourcesViewAllPrivate")}
|
||||
viewAllHref={showViewAllLinks ? privateViewAllHref : undefined}
|
||||
viewAllLabel={
|
||||
showViewAllLinks ? t("siteResourcesViewAllPrivate") : undefined
|
||||
}
|
||||
emptyLabel={t("siteResourcesEmptyPrivate")}
|
||||
isForbidden={privateForbidden}
|
||||
isFetching={privateQuery.isFetching}
|
||||
|
||||
@@ -27,6 +27,8 @@ import UserProfileCard from "@app/components/UserProfileCard";
|
||||
import SecurityKeyAuthButton from "@app/components/SecurityKeyAuthButton";
|
||||
import { Separator } from "@app/components/ui/separator";
|
||||
import OrgSignInLink from "@app/components/OrgSignInLink";
|
||||
import type { LoginFormIDP } from "./LoginForm";
|
||||
import IdpLoginButtons from "./IdpLoginButtons";
|
||||
|
||||
const identifierSchema = z.object({
|
||||
identifier: z.string().min(1, "Username or email is required")
|
||||
@@ -53,6 +55,7 @@ type SmartLoginFormProps = {
|
||||
forceLogin?: boolean;
|
||||
defaultUser?: string;
|
||||
orgSignIn?: OrgSignInConfig;
|
||||
lastUsedIdp?: (LoginFormIDP & { orgId?: string }) | null;
|
||||
};
|
||||
|
||||
type ViewState =
|
||||
@@ -89,7 +92,8 @@ export default function SmartLoginForm({
|
||||
redirect,
|
||||
forceLogin,
|
||||
defaultUser,
|
||||
orgSignIn
|
||||
orgSignIn,
|
||||
lastUsedIdp
|
||||
}: SmartLoginFormProps) {
|
||||
const router = useRouter();
|
||||
const { env } = useEnvContext();
|
||||
@@ -294,6 +298,15 @@ export default function SmartLoginForm({
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{lastUsedIdp && (
|
||||
<IdpLoginButtons
|
||||
idps={[lastUsedIdp]}
|
||||
orgId={lastUsedIdp.orgId}
|
||||
redirect={redirect}
|
||||
/>
|
||||
)}
|
||||
|
||||
<OrgSignInLink
|
||||
href={orgSignIn.href}
|
||||
linkText={orgSignIn.linkText}
|
||||
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
} from "next/navigation";
|
||||
import { cleanRedirect } from "@app/lib/cleanRedirect";
|
||||
import { Separator } from "@app/components/ui/separator";
|
||||
import { setClientCookie } from "@app/lib/setClientCookie";
|
||||
import { LAST_USED_IDP_COOKIE_NAME } from "@app/lib/consts";
|
||||
|
||||
type SmartLoginOrgSelectorProps = {
|
||||
identifier: string;
|
||||
@@ -141,6 +143,17 @@ export default function SmartLoginOrgSelector({
|
||||
setPendingIdpId(idpId);
|
||||
setError(null);
|
||||
|
||||
setClientCookie(
|
||||
LAST_USED_IDP_COOKIE_NAME,
|
||||
JSON.stringify({
|
||||
orgId,
|
||||
idpId
|
||||
}),
|
||||
{
|
||||
sameSite: "Lax"
|
||||
}
|
||||
);
|
||||
|
||||
let redirectToUrl: string | undefined;
|
||||
try {
|
||||
const safeRedirect = cleanRedirect(redirect || "/");
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { orgQueries } from "@app/lib/queries";
|
||||
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList
|
||||
} from "./ui/command";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { CheckIcon } from "lucide-react";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { useDebounce } from "use-debounce";
|
||||
import type { SelectedUser } from "./users-selector";
|
||||
|
||||
export type { SelectedUser };
|
||||
|
||||
export type UserSelectorProps = {
|
||||
orgId: string;
|
||||
selectedUser?: SelectedUser | null;
|
||||
onSelectUser: (user: SelectedUser | null) => void;
|
||||
allowClear?: boolean;
|
||||
};
|
||||
|
||||
export function UserSelector({
|
||||
orgId,
|
||||
selectedUser,
|
||||
onSelectUser,
|
||||
allowClear = true
|
||||
}: UserSelectorProps) {
|
||||
const t = useTranslations();
|
||||
const [userSearchQuery, setUserSearchQuery] = useState("");
|
||||
const [debouncedValue] = useDebounce(userSearchQuery, 150);
|
||||
|
||||
const { data: users = [] } = useQuery(
|
||||
orgQueries.users({ orgId, perPage: 10, query: debouncedValue })
|
||||
);
|
||||
|
||||
const usersShown = useMemo(() => {
|
||||
const allUsers: Array<SelectedUser> = users.map((u) => ({
|
||||
id: u.id,
|
||||
text: getUserDisplayName(u)
|
||||
}));
|
||||
if (
|
||||
debouncedValue.trim().length === 0 &&
|
||||
selectedUser &&
|
||||
!allUsers.find((user) => user.id === selectedUser.id)
|
||||
) {
|
||||
allUsers.unshift(selectedUser);
|
||||
}
|
||||
return allUsers;
|
||||
}, [users, selectedUser, debouncedValue]);
|
||||
|
||||
return (
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput
|
||||
placeholder={t("userSearch")}
|
||||
value={userSearchQuery}
|
||||
onValueChange={setUserSearchQuery}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>{t("usersNotFound")}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{allowClear && (
|
||||
<CommandItem
|
||||
value="__none__"
|
||||
onSelect={() => {
|
||||
onSelectUser(null);
|
||||
}}
|
||||
>
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
!selectedUser ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
{t("none")}
|
||||
</CommandItem>
|
||||
)}
|
||||
{usersShown.map((user) => (
|
||||
<CommandItem
|
||||
value={`${user.text}:${user.id}`}
|
||||
key={user.id}
|
||||
onSelect={() => {
|
||||
onSelectUser(user);
|
||||
}}
|
||||
>
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
user.id === selectedUser?.id
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
/>
|
||||
{user.text}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
);
|
||||
}
|
||||
@@ -4,9 +4,13 @@ import { getCachedSubscription } from "./getCachedSubscription";
|
||||
import { priv } from ".";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { GetLicenseStatusResponse } from "@server/routers/license/types";
|
||||
import { Tier } from "@server/types/Tiers";
|
||||
|
||||
export const isOrgSubscribed = cache(async (orgId: string) => {
|
||||
const DEFAULT_PAID_TIERS: Tier[] = ["tier1", "tier2", "tier3", "enterprise"];
|
||||
|
||||
export const isOrgSubscribed = cache(async (orgId: string, tiers?: Tier[]) => {
|
||||
let subscribed = false;
|
||||
const allowedTiers = tiers ?? DEFAULT_PAID_TIERS;
|
||||
|
||||
if (build === "enterprise") {
|
||||
try {
|
||||
@@ -20,7 +24,8 @@ export const isOrgSubscribed = cache(async (orgId: string) => {
|
||||
try {
|
||||
const subRes = await getCachedSubscription(orgId);
|
||||
subscribed =
|
||||
(subRes.data.data.tier == "tier1" || subRes.data.data.tier == "tier2" || subRes.data.data.tier == "tier3" || subRes.data.data.tier == "enterprise") &&
|
||||
!!subRes.data.data.tier &&
|
||||
allowedTiers.includes(subRes.data.data.tier as Tier) &&
|
||||
subRes.data.data.active;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const LAST_USED_IDP_COOKIE_NAME = "p__last_used_idp";
|
||||
@@ -1,6 +1,7 @@
|
||||
import { priv } from "@app/lib/api";
|
||||
import { isOrgSubscribed } from "@app/lib/api/isOrgSubscribed";
|
||||
import { build } from "@server/build";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import { LoadLoginPageBrandingResponse } from "@server/routers/loginPage/types";
|
||||
import { AxiosResponse } from "axios";
|
||||
|
||||
@@ -11,7 +12,10 @@ export async function loadOrgLoginPageBranding(orgId: string): Promise<{
|
||||
return { primaryColor: null };
|
||||
}
|
||||
|
||||
const subscribed = await isOrgSubscribed(orgId);
|
||||
const subscribed = await isOrgSubscribed(
|
||||
orgId,
|
||||
tierMatrix.loginPageBranding
|
||||
);
|
||||
if (!subscribed) {
|
||||
return { primaryColor: null };
|
||||
}
|
||||
|
||||
@@ -165,7 +165,6 @@ export function buildCreateSiteResourcePayload(
|
||||
siteIds: data.siteIds,
|
||||
mode: data.mode,
|
||||
destination: isNativeSsh ? undefined : (data.destination ?? undefined),
|
||||
enabled: true,
|
||||
...(data.mode === "http" && {
|
||||
scheme: data.scheme,
|
||||
ssl: data.ssl ?? false,
|
||||
@@ -342,7 +341,8 @@ export function createGeneralFormSchema(t: TranslateFn) {
|
||||
.string()
|
||||
.min(1)
|
||||
.max(255)
|
||||
.regex(/^[a-zA-Z0-9-]+$/)
|
||||
.regex(/^[a-zA-Z0-9-]+$/),
|
||||
enabled: z.boolean()
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+12
-5
@@ -672,9 +672,13 @@ export const orgQueries = {
|
||||
queryOptions({
|
||||
queryKey: ["SITE_STATUS_HISTORY", siteId, days] as const,
|
||||
queryFn: async ({ signal, meta }) => {
|
||||
const tzOffsetMinutes = -new Date().getTimezoneOffset();
|
||||
const res = await meta!.api.get<
|
||||
AxiosResponse<StatusHistoryResponse>
|
||||
>(`/site/${siteId}/status-history?days=${days}`, { signal });
|
||||
>(
|
||||
`/site/${siteId}/status-history?days=${days}&tzOffsetMinutes=${tzOffsetMinutes}`,
|
||||
{ signal }
|
||||
);
|
||||
return res.data.data;
|
||||
}
|
||||
}),
|
||||
@@ -689,11 +693,13 @@ export const orgQueries = {
|
||||
queryOptions({
|
||||
queryKey: ["RESOURCE_STATUS_HISTORY", resourceId, days] as const,
|
||||
queryFn: async ({ signal, meta }) => {
|
||||
const tzOffsetMinutes = -new Date().getTimezoneOffset();
|
||||
const res = await meta!.api.get<
|
||||
AxiosResponse<StatusHistoryResponse>
|
||||
>(`/resource/${resourceId}/status-history?days=${days}`, {
|
||||
signal
|
||||
});
|
||||
>(
|
||||
`/resource/${resourceId}/status-history?days=${days}&tzOffsetMinutes=${tzOffsetMinutes}`,
|
||||
{ signal }
|
||||
);
|
||||
return res.data.data;
|
||||
}
|
||||
}),
|
||||
@@ -715,10 +721,11 @@ export const orgQueries = {
|
||||
days
|
||||
] as const,
|
||||
queryFn: async ({ signal, meta }) => {
|
||||
const tzOffsetMinutes = -new Date().getTimezoneOffset();
|
||||
const res = await meta!.api.get<
|
||||
AxiosResponse<StatusHistoryResponse>
|
||||
>(
|
||||
`/org/${orgId}/health-check/${healthCheckId}/status-history?days=${days}`,
|
||||
`/org/${orgId}/health-check/${healthCheckId}/status-history?days=${days}&tzOffsetMinutes=${tzOffsetMinutes}`,
|
||||
{ signal }
|
||||
);
|
||||
return res.data.data;
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Set a cookie on the client side in javascript code, not on the server
|
||||
* @param name
|
||||
* @param value
|
||||
* @param days
|
||||
* @param options
|
||||
*/
|
||||
export function setClientCookie(
|
||||
name: string,
|
||||
value: string,
|
||||
options: {
|
||||
days?: number;
|
||||
path?: string;
|
||||
secure?: boolean;
|
||||
sameSite?: "Strict" | "Lax" | "None";
|
||||
} = {}
|
||||
): void {
|
||||
let cookie = `${encodeURIComponent(name)}=${encodeURIComponent(value)}`;
|
||||
|
||||
if (options.days) {
|
||||
const date = new Date();
|
||||
date.setTime(date.getTime() + options.days * 864e5);
|
||||
cookie += `; expires=${date.toUTCString()}`;
|
||||
}
|
||||
|
||||
cookie += `; path=${options.path ?? "/"}`;
|
||||
|
||||
if (options.secure) cookie += "; Secure";
|
||||
if (options.sameSite) cookie += `; SameSite=${options.sameSite}`;
|
||||
|
||||
document.cookie = cookie;
|
||||
}
|
||||
Reference in New Issue
Block a user