mirror of
https://github.com/fosrl/pangolin.git
synced 2026-07-13 17:21:48 +02:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bada1fdb97 | |||
| d9303f87c8 | |||
| 14680df160 | |||
| 34d5c9535d | |||
| dc60ef712f | |||
| 2fe85ebda2 | |||
| 3d13e9105c | |||
| 289be30e6b | |||
| a74c0c227c | |||
| 05bf77da29 | |||
| bb4deb1ae9 |
@@ -41,7 +41,7 @@ services:
|
||||
- 80:80 # Port for traefik because of the network_mode
|
||||
|
||||
traefik:
|
||||
image: traefik:v3.7
|
||||
image: traefik:v3.6
|
||||
container_name: traefik
|
||||
restart: unless-stopped
|
||||
network_mode: service:gerbil # Ports appear on the gerbil service
|
||||
|
||||
@@ -50,7 +50,7 @@ services:
|
||||
- 80:80{{end}}
|
||||
|
||||
traefik:
|
||||
image: docker.io/traefik:v3.7
|
||||
image: docker.io/traefik:v3.6
|
||||
container_name: traefik
|
||||
restart: unless-stopped
|
||||
{{if .InstallGerbil}}network_mode: service:gerbil # Ports appear on the gerbil service{{end}}{{if not .InstallGerbil}}
|
||||
|
||||
@@ -1517,6 +1517,7 @@
|
||||
"otpAuthDescription": "Enter the code from your authenticator app or one of your single-use backup codes.",
|
||||
"otpAuthSubmit": "Submit Code",
|
||||
"idpContinue": "Or continue with",
|
||||
"idpLastUsed": "Last Used",
|
||||
"otpAuthBack": "Back to Password",
|
||||
"navbar": "Navigation Menu",
|
||||
"navbarDescription": "Main navigation menu for the application",
|
||||
|
||||
Generated
+2848
-1739
File diff suppressed because it is too large
Load Diff
+60
-60
@@ -35,7 +35,7 @@
|
||||
"@asteasolutions/zod-to-openapi": "8.5.0",
|
||||
"@devolutions/iron-remote-desktop": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-0.0.0.tgz",
|
||||
"@devolutions/iron-remote-desktop-rdp": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-rdp-0.0.1.tgz",
|
||||
"@aws-sdk/client-s3": "3.1085.0",
|
||||
"@aws-sdk/client-s3": "3.1056.0",
|
||||
"@headlessui/react": "2.2.10",
|
||||
"@hookform/resolvers": "5.4.0",
|
||||
"@monaco-editor/react": "4.7.0",
|
||||
@@ -43,38 +43,38 @@
|
||||
"@novnc/novnc": "^1.7.0",
|
||||
"@oslojs/crypto": "1.0.1",
|
||||
"@oslojs/encoding": "1.1.0",
|
||||
"@radix-ui/react-avatar": "1.2.2",
|
||||
"@radix-ui/react-checkbox": "1.3.7",
|
||||
"@radix-ui/react-collapsible": "1.1.16",
|
||||
"@radix-ui/react-dialog": "1.1.19",
|
||||
"@radix-ui/react-dropdown-menu": "2.1.20",
|
||||
"@radix-ui/react-avatar": "1.1.11",
|
||||
"@radix-ui/react-checkbox": "1.3.3",
|
||||
"@radix-ui/react-collapsible": "1.1.12",
|
||||
"@radix-ui/react-dialog": "1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "2.1.16",
|
||||
"@radix-ui/react-icons": "1.3.2",
|
||||
"@radix-ui/react-label": "2.1.11",
|
||||
"@radix-ui/react-popover": "1.1.19",
|
||||
"@radix-ui/react-progress": "1.1.12",
|
||||
"@radix-ui/react-radio-group": "1.4.3",
|
||||
"@radix-ui/react-scroll-area": "1.2.14",
|
||||
"@radix-ui/react-select": "2.3.3",
|
||||
"@radix-ui/react-separator": "1.1.11",
|
||||
"@radix-ui/react-slot": "1.3.0",
|
||||
"@radix-ui/react-switch": "1.3.3",
|
||||
"@radix-ui/react-tabs": "1.1.17",
|
||||
"@radix-ui/react-toast": "1.2.19",
|
||||
"@radix-ui/react-tooltip": "1.2.12",
|
||||
"@radix-ui/react-label": "2.1.8",
|
||||
"@radix-ui/react-popover": "1.1.15",
|
||||
"@radix-ui/react-progress": "1.1.8",
|
||||
"@radix-ui/react-radio-group": "1.3.8",
|
||||
"@radix-ui/react-scroll-area": "1.2.10",
|
||||
"@radix-ui/react-select": "2.2.6",
|
||||
"@radix-ui/react-separator": "1.1.8",
|
||||
"@radix-ui/react-slot": "1.2.4",
|
||||
"@radix-ui/react-switch": "1.2.6",
|
||||
"@radix-ui/react-tabs": "1.1.13",
|
||||
"@radix-ui/react-toast": "1.2.15",
|
||||
"@radix-ui/react-tooltip": "1.2.8",
|
||||
"@react-email/body": "0.3.0",
|
||||
"@react-email/components": "1.0.12",
|
||||
"@react-email/render": "2.1.0",
|
||||
"@react-email/render": "2.0.8",
|
||||
"@react-email/tailwind": "2.0.7",
|
||||
"@simplewebauthn/browser": "13.3.0",
|
||||
"@simplewebauthn/server": "13.3.2",
|
||||
"@simplewebauthn/server": "13.3.1",
|
||||
"@tailwindcss/forms": "0.5.11",
|
||||
"@tanstack/react-query": "5.101.2",
|
||||
"@tanstack/react-query": "5.100.14",
|
||||
"@tanstack/react-table": "8.21.3",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-web-links": "^0.12.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"arctic": "3.7.0",
|
||||
"axios": "1.18.1",
|
||||
"axios": "1.16.1",
|
||||
"better-sqlite3": "11.9.1",
|
||||
"canvas-confetti": "1.9.4",
|
||||
"class-variance-authority": "0.7.1",
|
||||
@@ -88,43 +88,43 @@
|
||||
"express": "5.2.1",
|
||||
"express-rate-limit": "8.5.2",
|
||||
"glob": "13.0.6",
|
||||
"helmet": "8.3.0",
|
||||
"helmet": "8.2.0",
|
||||
"http-errors": "2.0.1",
|
||||
"input-otp": "1.4.2",
|
||||
"ioredis": "5.11.1",
|
||||
"ioredis": "5.11.0",
|
||||
"jmespath": "0.16.0",
|
||||
"js-yaml": "5.2.1",
|
||||
"js-yaml": "4.2.0",
|
||||
"jsonwebtoken": "9.0.3",
|
||||
"lucide-react": "1.24.0",
|
||||
"lucide-react": "1.17.0",
|
||||
"maxmind": "5.0.6",
|
||||
"moment": "2.30.1",
|
||||
"next": "16.2.10",
|
||||
"next-intl": "4.13.2",
|
||||
"next": "16.2.6",
|
||||
"next-intl": "4.13.0",
|
||||
"next-themes": "0.4.6",
|
||||
"nextjs-toploader": "3.9.17",
|
||||
"node-cache": "5.1.2",
|
||||
"nodemailer": "9.0.3",
|
||||
"nodemailer": "9.0.1",
|
||||
"oslo": "1.2.1",
|
||||
"pg": "8.22.0",
|
||||
"posthog-node": "5.41.0",
|
||||
"pg": "8.21.0",
|
||||
"posthog-node": "5.35.6",
|
||||
"qrcode.react": "4.2.0",
|
||||
"react": "19.2.7",
|
||||
"react-day-picker": "10.0.1",
|
||||
"react-dom": "19.2.7",
|
||||
"react": "19.2.6",
|
||||
"react-day-picker": "9.14.0",
|
||||
"react-dom": "19.2.6",
|
||||
"react-easy-sort": "1.8.0",
|
||||
"react-hook-form": "7.81.0",
|
||||
"react-icons": "5.7.0",
|
||||
"recharts": "3.9.2",
|
||||
"react-hook-form": "7.76.1",
|
||||
"react-icons": "5.6.0",
|
||||
"recharts": "3.8.1",
|
||||
"reodotdev": "1.1.0",
|
||||
"semver": "7.8.5",
|
||||
"semver": "7.8.1",
|
||||
"sshpk": "1.18.0",
|
||||
"stripe": "22.3.1",
|
||||
"stripe": "22.2.0",
|
||||
"swagger-ui-express": "5.0.1",
|
||||
"tailwind-merge": "3.6.0",
|
||||
"topojson-client": "3.1.0",
|
||||
"tw-animate-css": "1.4.0",
|
||||
"use-debounce": "10.1.1",
|
||||
"uuid": "14.0.1",
|
||||
"uuid": "14.0.0",
|
||||
"vaul": "1.1.2",
|
||||
"visionscarto-world-atlas": "1.0.0",
|
||||
"winston": "3.19.0",
|
||||
@@ -136,11 +136,11 @@
|
||||
"zod-validation-error": "5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@dotenvx/dotenvx": "2.6.0",
|
||||
"@dotenvx/dotenvx": "1.69.1",
|
||||
"@esbuild-plugins/tsconfig-paths": "0.1.2",
|
||||
"@react-email/ui": "^6.7.0",
|
||||
"@tailwindcss/postcss": "4.3.2",
|
||||
"@tanstack/react-query-devtools": "5.101.2",
|
||||
"@react-email/ui": "^6.5.0",
|
||||
"@tailwindcss/postcss": "4.3.0",
|
||||
"@tanstack/react-query-devtools": "5.100.14",
|
||||
"@types/better-sqlite3": "7.6.13",
|
||||
"@types/cookie-parser": "1.4.10",
|
||||
"@types/cors": "2.8.19",
|
||||
@@ -151,14 +151,14 @@
|
||||
"@types/jmespath": "0.15.2",
|
||||
"@types/js-yaml": "4.0.9",
|
||||
"@types/jsonwebtoken": "9.0.10",
|
||||
"@types/node": "26.1.1",
|
||||
"@types/nodemailer": "8.0.1",
|
||||
"@types/node": "25.9.1",
|
||||
"@types/nodemailer": "8.0.0",
|
||||
"@types/nprogress": "0.2.3",
|
||||
"@types/pg": "8.20.0",
|
||||
"@types/react": "19.2.17",
|
||||
"@types/react": "19.2.15",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@types/semver": "7.7.1",
|
||||
"@types/sshpk": "1.17.5",
|
||||
"@types/sshpk": "1.17.4",
|
||||
"@types/swagger-ui-express": "4.1.8",
|
||||
"@types/topojson-client": "3.1.5",
|
||||
"@types/ws": "8.18.1",
|
||||
@@ -166,21 +166,21 @@
|
||||
"babel-plugin-react-compiler": "1.0.0",
|
||||
"drizzle-kit": "0.31.10",
|
||||
"esbuild": "0.28.1",
|
||||
"esbuild-node-externals": "1.23.1",
|
||||
"eslint": "10.7.0",
|
||||
"eslint-config-next": "16.2.10",
|
||||
"postcss": "8.5.18",
|
||||
"prettier": "3.9.5",
|
||||
"react-email": "6.7.0",
|
||||
"tailwindcss": "4.3.2",
|
||||
"tsc-alias": "1.9.0",
|
||||
"tsx": "4.23.0",
|
||||
"typescript": "7.0.2",
|
||||
"typescript-eslint": "8.63.0"
|
||||
"esbuild-node-externals": "1.22.0",
|
||||
"eslint": "10.4.0",
|
||||
"eslint-config-next": "16.2.6",
|
||||
"postcss": "8.5.15",
|
||||
"prettier": "3.8.3",
|
||||
"react-email": "6.5.0",
|
||||
"tailwindcss": "4.3.0",
|
||||
"tsc-alias": "1.8.17",
|
||||
"tsx": "4.22.3",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.60.0"
|
||||
},
|
||||
"overrides": {
|
||||
"esbuild": "0.28.1",
|
||||
"dompurify": "3.4.0",
|
||||
"postcss": "8.5.18"
|
||||
"postcss": "8.5.15"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 || "/");
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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