From 785173747f3995fbfd9982743cec02c554807137 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Wed, 16 Apr 2025 03:28:04 +0800 Subject: [PATCH] misc: introduce admin login portal --- .../src/services/auth/auth-login-service.ts | 57 ++++++++++++------- .../src/pages/auth/AdminLoginPage/route.tsx | 7 +++ .../src/pages/auth/LoginPage/LoginPage.tsx | 3 +- .../components/InitialStep/InitialStep.tsx | 31 +++++----- .../OrgAuthTab/OrgGeneralAuthSection.tsx | 38 ++++++++++++- .../components/OrgAuthTab/OrgOIDCSection.tsx | 35 +++++++++++- frontend/src/routeTree.gen.ts | 27 +++++++++ frontend/src/routes.ts | 1 + 8 files changed, 157 insertions(+), 42 deletions(-) create mode 100644 frontend/src/pages/auth/AdminLoginPage/route.tsx diff --git a/backend/src/services/auth/auth-login-service.ts b/backend/src/services/auth/auth-login-service.ts index 8dfe69643..0e0f999dd 100644 --- a/backend/src/services/auth/auth-login-service.ts +++ b/backend/src/services/auth/auth-login-service.ts @@ -2,7 +2,7 @@ import bcrypt from "bcrypt"; import jwt from "jsonwebtoken"; import { Knex } from "knex"; -import { TUsers, UserDeviceSchema } from "@app/db/schemas"; +import { OrgMembershipRole, TUsers, UserDeviceSchema } from "@app/db/schemas"; import { isAuthMethodSaml } from "@app/ee/services/permission/permission-fns"; import { getConfig } from "@app/lib/config/env"; import { request } from "@app/lib/config/request"; @@ -174,20 +174,25 @@ export const authLoginServiceFactory = ({ const userEnc = await userDAL.findUserEncKeyByUsername({ username: email }); + const serverCfg = await getServerCfg(); + if (!userEnc || (userEnc && !userEnc.isAccepted)) { + throw new Error("Failed to find user"); + } + if ( serverCfg.enabledLoginMethods && !serverCfg.enabledLoginMethods.includes(LoginMethod.EMAIL) && !providerAuthToken ) { - throw new BadRequestError({ - message: "Login with email is disabled by administrator." - }); - } - - if (!userEnc || (userEnc && !userEnc.isAccepted)) { - throw new Error("Failed to find user"); + // bypass server configuration when user is an organization admin - this is to prevent lockout + const userOrgs = await orgDAL.findAllOrgsByUserId(userEnc.userId); + if (!userOrgs.some((org) => org.userRole === OrgMembershipRole.Admin)) { + throw new BadRequestError({ + message: "Login with email is disabled by administrator." + }); + } } if (!userEnc.authMethods?.includes(AuthMethod.EMAIL)) { @@ -573,28 +578,40 @@ export const authLoginServiceFactory = ({ switch (authMethod) { case AuthMethod.GITHUB: { if (!serverCfg.enabledLoginMethods.includes(LoginMethod.GITHUB)) { - throw new BadRequestError({ - message: "Login with Github is disabled by administrator.", - name: "Oauth 2 login" - }); + // bypass server configuration when user is an organization admin - this is to prevent lockout + const userOrgs = await orgDAL.findAllOrgsByUserId(user.id); + if (!userOrgs.some((org) => org.userRole === OrgMembershipRole.Admin)) { + throw new BadRequestError({ + message: "Login with Github is disabled by administrator.", + name: "Oauth 2 login" + }); + } } break; } case AuthMethod.GOOGLE: { if (!serverCfg.enabledLoginMethods.includes(LoginMethod.GOOGLE)) { - throw new BadRequestError({ - message: "Login with Google is disabled by administrator.", - name: "Oauth 2 login" - }); + // bypass server configuration when user is an organization admin - this is to prevent lockout + const userOrgs = await orgDAL.findAllOrgsByUserId(user.id); + if (!userOrgs.some((org) => org.userRole === OrgMembershipRole.Admin)) { + throw new BadRequestError({ + message: "Login with Google is disabled by administrator.", + name: "Oauth 2 login" + }); + } } break; } case AuthMethod.GITLAB: { if (!serverCfg.enabledLoginMethods.includes(LoginMethod.GITLAB)) { - throw new BadRequestError({ - message: "Login with Gitlab is disabled by administrator.", - name: "Oauth 2 login" - }); + // bypass server configuration when user is an organization admin - this is to prevent lockout + const userOrgs = await orgDAL.findAllOrgsByUserId(user.id); + if (!userOrgs.some((org) => org.userRole === OrgMembershipRole.Admin)) { + throw new BadRequestError({ + message: "Login with Gitlab is disabled by administrator.", + name: "Oauth 2 login" + }); + } } break; } diff --git a/frontend/src/pages/auth/AdminLoginPage/route.tsx b/frontend/src/pages/auth/AdminLoginPage/route.tsx new file mode 100644 index 000000000..221c6a34e --- /dev/null +++ b/frontend/src/pages/auth/AdminLoginPage/route.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { LoginPage } from "../LoginPage/LoginPage"; + +export const Route = createFileRoute("/_restrict-login-signup/login/admin")({ + component: () => +}); diff --git a/frontend/src/pages/auth/LoginPage/LoginPage.tsx b/frontend/src/pages/auth/LoginPage/LoginPage.tsx index 74ced95ef..97bb528c6 100644 --- a/frontend/src/pages/auth/LoginPage/LoginPage.tsx +++ b/frontend/src/pages/auth/LoginPage/LoginPage.tsx @@ -8,7 +8,7 @@ import { isLoggedIn } from "@app/hooks/api/reactQuery"; import { InitialStep, SSOStep } from "./components"; import { useNavigateToSelectOrganization } from "./Login.utils"; -export const LoginPage = () => { +export const LoginPage = ({ isAdmin }: { isAdmin?: boolean }) => { const { t } = useTranslation(); const [step, setStep] = useState(0); const [email, setEmail] = useState(""); @@ -44,6 +44,7 @@ export const LoginPage = () => { case 0: return ( void; password: string; setPassword: (email: string) => void; + isAdmin?: boolean; }; -export const InitialStep = ({ setStep, email, setEmail, password, setPassword }: Props) => { +export const InitialStep = ({ + setStep, + email, + setEmail, + password, + setPassword, + isAdmin +}: Props) => { const navigate = useNavigate(); const { t } = useTranslation(); const [isLoading, setIsLoading] = useState(false); const [loginError, setLoginError] = useState(false); - const [isOtherLoginMethodsSelected, setIsOtherLoginMethodsSelected] = useState(false); const { config } = useServerConfig(); const queryParams = new URLSearchParams(window.location.search); const [captchaToken, setCaptchaToken] = useState(""); @@ -63,7 +70,9 @@ export const InitialStep = ({ setStep, email, setEmail, password, setPassword }: }; useEffect(() => { - if (serverDetails?.samlDefaultOrgSlug) redirectToSaml(serverDetails.samlDefaultOrgSlug); + if (serverDetails?.samlDefaultOrgSlug && !isAdmin) { + redirectToSaml(serverDetails.samlDefaultOrgSlug); + } }, [serverDetails?.samlDefaultOrgSlug]); const handleSaml = () => { @@ -83,7 +92,7 @@ export const InitialStep = ({ setStep, email, setEmail, password, setPassword }: }; const shouldDisplayLoginMethod = (method: LoginMethod) => - !config.enabledLoginMethods || config.enabledLoginMethods.includes(method); + isAdmin || !config.enabledLoginMethods || config.enabledLoginMethods.includes(method); const handleLogin = async (e: FormEvent) => { e.preventDefault(); @@ -161,11 +170,7 @@ export const InitialStep = ({ setStep, email, setEmail, password, setPassword }: setIsLoading(false); }; - if ( - config.defaultAuthOrgAuthEnforced && - config.defaultAuthOrgAuthMethod && - !isOtherLoginMethodsSelected - ) { + if (config.defaultAuthOrgAuthEnforced && config.defaultAuthOrgAuthMethod && !isAdmin) { return (
)} -
); } diff --git a/frontend/src/pages/organization/SettingsPage/components/OrgAuthTab/OrgGeneralAuthSection.tsx b/frontend/src/pages/organization/SettingsPage/components/OrgAuthTab/OrgGeneralAuthSection.tsx index 6bcdef231..291934208 100644 --- a/frontend/src/pages/organization/SettingsPage/components/OrgAuthTab/OrgGeneralAuthSection.tsx +++ b/frontend/src/pages/organization/SettingsPage/components/OrgAuthTab/OrgGeneralAuthSection.tsx @@ -1,7 +1,10 @@ +import { faInfoCircle } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; import { createNotification } from "@app/components/notifications"; import { OrgPermissionCan } from "@app/components/permissions"; -import { Switch } from "@app/components/v2"; +import { Switch, Tooltip } from "@app/components/v2"; import { OrgPermissionActions, OrgPermissionSubjects, @@ -72,7 +75,38 @@ export const OrgGeneralAuthSection = () => { */}
-

Enforce SAML SSO

+
+ Enforce SAML SSO + + + Login enforcement is only applied to non-admin users in order to prevent total + lockout from the organization when the SAML provider is unavailable. + + +

+ In case of a lockout, use the admin login portal{" "} + + here. + +

+
+ } + > + + +
{(isAllowed) => ( { )}
-

Enforce OIDC SSO

+
+ Enforce OIDC SSO + + + Login enforcement is only applied to non-admin users in order to prevent total + lockout from the organization when the OIDC provider is unavailable. + + +

+ In case of a lockout, use the admin login portal{" "} + + here. + +

+
+ } + > + + +
{(isAllowed) => ( {

- Enforce non-admin users to authenticate via OIDC to access this organization. + Enforce non-admin users to authenticate via OIDC to access this organization.

diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index 7cd0b5ea9..e3ab6df7d 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -33,6 +33,7 @@ import { Route as authSignUpSsoPageRouteImport } from './pages/auth/SignUpSsoPag import { Route as authLoginSsoPageRouteImport } from './pages/auth/LoginSsoPage/route' import { Route as authSelectOrgPageRouteImport } from './pages/auth/SelectOrgPage/route' import { Route as authLoginLdapPageRouteImport } from './pages/auth/LoginLdapPage/route' +import { Route as authAdminLoginPageRouteImport } from './pages/auth/AdminLoginPage/route' import { Route as adminSignUpPageRouteImport } from './pages/admin/SignUpPage/route' import { Route as organizationNoOrgPageRouteImport } from './pages/organization/NoOrgPage/route' import { Route as authSignUpPageRouteImport } from './pages/auth/SignUpPage/route' @@ -393,6 +394,12 @@ const authLoginLdapPageRouteRoute = authLoginLdapPageRouteImport.update({ getParentRoute: () => RestrictLoginSignupLoginRoute, } as any) +const authAdminLoginPageRouteRoute = authAdminLoginPageRouteImport.update({ + id: '/admin', + path: '/admin', + getParentRoute: () => RestrictLoginSignupLoginRoute, +} as any) + const adminSignUpPageRouteRoute = adminSignUpPageRouteImport.update({ id: '/admin/signup', path: '/admin/signup', @@ -1775,6 +1782,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof adminSignUpPageRouteImport parentRoute: typeof middlewaresRestrictLoginSignupImport } + '/_restrict-login-signup/login/admin': { + id: '/_restrict-login-signup/login/admin' + path: '/admin' + fullPath: '/login/admin' + preLoaderRoute: typeof authAdminLoginPageRouteImport + parentRoute: typeof RestrictLoginSignupLoginImport + } '/_restrict-login-signup/login/ldap': { id: '/_restrict-login-signup/login/ldap' path: '/ldap' @@ -3655,6 +3669,7 @@ const middlewaresAuthenticateRouteWithChildren = interface RestrictLoginSignupLoginRouteChildren { authLoginPageRouteRoute: typeof authLoginPageRouteRoute + authAdminLoginPageRouteRoute: typeof authAdminLoginPageRouteRoute authLoginLdapPageRouteRoute: typeof authLoginLdapPageRouteRoute authSelectOrgPageRouteRoute: typeof authSelectOrgPageRouteRoute authLoginSsoPageRouteRoute: typeof authLoginSsoPageRouteRoute @@ -3665,6 +3680,7 @@ interface RestrictLoginSignupLoginRouteChildren { const RestrictLoginSignupLoginRouteChildren: RestrictLoginSignupLoginRouteChildren = { authLoginPageRouteRoute: authLoginPageRouteRoute, + authAdminLoginPageRouteRoute: authAdminLoginPageRouteRoute, authLoginLdapPageRouteRoute: authLoginLdapPageRouteRoute, authSelectOrgPageRouteRoute: authSelectOrgPageRouteRoute, authLoginSsoPageRouteRoute: authLoginSsoPageRouteRoute, @@ -3739,6 +3755,7 @@ export interface FileRoutesByFullPath { '/signup/': typeof authSignUpPageRouteRoute '/organization/none': typeof organizationNoOrgPageRouteRoute '/admin/signup': typeof adminSignUpPageRouteRoute + '/login/admin': typeof authAdminLoginPageRouteRoute '/login/ldap': typeof authLoginLdapPageRouteRoute '/login/select-organization': typeof authSelectOrgPageRouteRoute '/login/sso': typeof authLoginSsoPageRouteRoute @@ -3918,6 +3935,7 @@ export interface FileRoutesByTo { '/signup': typeof authSignUpPageRouteRoute '/organization/none': typeof organizationNoOrgPageRouteRoute '/admin/signup': typeof adminSignUpPageRouteRoute + '/login/admin': typeof authAdminLoginPageRouteRoute '/login/ldap': typeof authLoginLdapPageRouteRoute '/login/select-organization': typeof authSelectOrgPageRouteRoute '/login/sso': typeof authLoginSsoPageRouteRoute @@ -4096,6 +4114,7 @@ export interface FileRoutesById { '/_restrict-login-signup/signup/': typeof authSignUpPageRouteRoute '/_authenticate/organization/none': typeof organizationNoOrgPageRouteRoute '/_restrict-login-signup/admin/signup': typeof adminSignUpPageRouteRoute + '/_restrict-login-signup/login/admin': typeof authAdminLoginPageRouteRoute '/_restrict-login-signup/login/ldap': typeof authLoginLdapPageRouteRoute '/_restrict-login-signup/login/select-organization': typeof authSelectOrgPageRouteRoute '/_restrict-login-signup/login/sso': typeof authLoginSsoPageRouteRoute @@ -4286,6 +4305,7 @@ export interface FileRouteTypes { | '/signup/' | '/organization/none' | '/admin/signup' + | '/login/admin' | '/login/ldap' | '/login/select-organization' | '/login/sso' @@ -4464,6 +4484,7 @@ export interface FileRouteTypes { | '/signup' | '/organization/none' | '/admin/signup' + | '/login/admin' | '/login/ldap' | '/login/select-organization' | '/login/sso' @@ -4640,6 +4661,7 @@ export interface FileRouteTypes { | '/_restrict-login-signup/signup/' | '/_authenticate/organization/none' | '/_restrict-login-signup/admin/signup' + | '/_restrict-login-signup/login/admin' | '/_restrict-login-signup/login/ldap' | '/_restrict-login-signup/login/select-organization' | '/_restrict-login-signup/login/sso' @@ -4928,6 +4950,7 @@ export const routeTree = rootRoute "parent": "/_restrict-login-signup", "children": [ "/_restrict-login-signup/login/", + "/_restrict-login-signup/login/admin", "/_restrict-login-signup/login/ldap", "/_restrict-login-signup/login/select-organization", "/_restrict-login-signup/login/sso", @@ -4959,6 +4982,10 @@ export const routeTree = rootRoute "filePath": "admin/SignUpPage/route.tsx", "parent": "/_restrict-login-signup" }, + "/_restrict-login-signup/login/admin": { + "filePath": "auth/AdminLoginPage/route.tsx", + "parent": "/_restrict-login-signup/login" + }, "/_restrict-login-signup/login/ldap": { "filePath": "auth/LoginLdapPage/route.tsx", "parent": "/_restrict-login-signup/login" diff --git a/frontend/src/routes.ts b/frontend/src/routes.ts index 47f2d6ab1..d6793ea1a 100644 --- a/frontend/src/routes.ts +++ b/frontend/src/routes.ts @@ -328,6 +328,7 @@ export const routes = rootRoute("root.tsx", [ route("/admin/signup", "admin/SignUpPage/route.tsx"), route("/login", [ index("auth/LoginPage/route.tsx"), + route("/admin", "auth/AdminLoginPage/route.tsx"), route("/select-organization", "auth/SelectOrgPage/route.tsx"), route("/sso", "auth/LoginSsoPage/route.tsx"), route("/ldap", "auth/LoginLdapPage/route.tsx"),