mirror of
https://github.com/fosrl/pangolin.git
synced 2025-07-28 06:34:50 +02:00
show list of idp on login
This commit is contained in:
parent
b4fda6a1f6
commit
8fa719181a
9 changed files with 156 additions and 70 deletions
|
@ -10,6 +10,7 @@ import {
|
|||
idp,
|
||||
idpOidcConfig,
|
||||
idpOrg,
|
||||
orgs,
|
||||
Role,
|
||||
roles,
|
||||
userOrgs,
|
||||
|
@ -214,32 +215,47 @@ export async function validateOidcCallback(
|
|||
);
|
||||
|
||||
if (existingIdp.idp.autoProvision) {
|
||||
const idpOrgs = await db
|
||||
.select()
|
||||
.from(idpOrg)
|
||||
.where(eq(idpOrg.idpId, existingIdp.idp.idpId));
|
||||
const allOrgs = await db.select().from(orgs);
|
||||
|
||||
const defaultRoleMapping = existingIdp.idp.defaultRoleMapping;
|
||||
const defaultOrgMapping = existingIdp.idp.defaultOrgMapping;
|
||||
|
||||
let userOrgInfo: { orgId: string; roleId: number }[] = [];
|
||||
for (const idpOrg of idpOrgs) {
|
||||
for (const org of allOrgs) {
|
||||
const [idpOrgRes] = await db
|
||||
.select()
|
||||
.from(idpOrg)
|
||||
.where(
|
||||
and(
|
||||
eq(idpOrg.idpId, existingIdp.idp.idpId),
|
||||
eq(idpOrg.orgId, org.orgId)
|
||||
)
|
||||
);
|
||||
|
||||
let roleId: number | undefined = undefined;
|
||||
|
||||
const orgMapping = idpOrg.orgMapping || defaultOrgMapping;
|
||||
const hydratedOrgMapping = orgMapping
|
||||
?.split("{{orgId}}")
|
||||
.join(idpOrg.orgId);
|
||||
const orgMapping = idpOrgRes?.orgMapping || defaultOrgMapping;
|
||||
const hydratedOrgMapping = hydrateOrgMapping(
|
||||
orgMapping,
|
||||
org.orgId
|
||||
);
|
||||
|
||||
if (hydratedOrgMapping) {
|
||||
logger.debug("Hydrated Org Mapping", {
|
||||
hydratedOrgMapping
|
||||
});
|
||||
const orgId = jmespath.search(claims, hydratedOrgMapping);
|
||||
if (!(orgId === true || orgId === idpOrg.orgId)) {
|
||||
logger.debug("Extraced Org ID", { orgId });
|
||||
if (orgId !== true && orgId !== org.orgId) {
|
||||
// user not allowed to access this org
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const roleMapping = idpOrg.roleMapping || defaultRoleMapping;
|
||||
const roleMapping =
|
||||
idpOrgRes?.roleMapping || defaultRoleMapping;
|
||||
if (roleMapping) {
|
||||
logger.debug("Role Mapping", { roleMapping });
|
||||
const roleName = jmespath.search(claims, roleMapping);
|
||||
|
||||
if (!roleName) {
|
||||
|
@ -254,14 +270,14 @@ export async function validateOidcCallback(
|
|||
.from(roles)
|
||||
.where(
|
||||
and(
|
||||
eq(roles.orgId, idpOrg.orgId),
|
||||
eq(roles.orgId, org.orgId),
|
||||
eq(roles.name, roleName)
|
||||
)
|
||||
);
|
||||
|
||||
if (!roleRes) {
|
||||
logger.error("Role not found", {
|
||||
orgId: idpOrg.orgId,
|
||||
orgId: org.orgId,
|
||||
roleName
|
||||
});
|
||||
continue;
|
||||
|
@ -270,7 +286,7 @@ export async function validateOidcCallback(
|
|||
roleId = roleRes.roleId;
|
||||
|
||||
userOrgInfo.push({
|
||||
orgId: idpOrg.orgId,
|
||||
orgId: org.orgId,
|
||||
roleId
|
||||
});
|
||||
}
|
||||
|
@ -443,3 +459,13 @@ export async function validateOidcCallback(
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
function hydrateOrgMapping(
|
||||
orgMapping: string | null,
|
||||
orgId: string
|
||||
): string | undefined {
|
||||
if (!orgMapping) {
|
||||
return undefined;
|
||||
}
|
||||
return orgMapping.split("{{orgId}}").join(orgId);
|
||||
}
|
||||
|
|
|
@ -101,7 +101,7 @@ export default function GeneralPage() {
|
|||
emailPath: data.idpOidcConfig.emailPath,
|
||||
namePath: data.idpOidcConfig.namePath,
|
||||
scopes: data.idpOidcConfig.scopes,
|
||||
autoProvision: data.idpOidcConfig.autoProvision
|
||||
autoProvision: data.idp.autoProvision
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
|
|
|
@ -22,7 +22,7 @@ type ValidateOidcTokenParams = {
|
|||
code: string | undefined;
|
||||
expectedState: string | undefined;
|
||||
stateCookie: string | undefined;
|
||||
idp: {name: string};
|
||||
idp: { name: string };
|
||||
};
|
||||
|
||||
export default function ValidateOidcToken(props: ValidateOidcTokenParams) {
|
||||
|
@ -64,11 +64,10 @@ export default function ValidateOidcToken(props: ValidateOidcTokenParams) {
|
|||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
if (redirectUrl.startsWith("http")) {
|
||||
window.location.href = res.data.data.redirectUrl; // TODO: validate this to make sure it's safe
|
||||
window.location.href = res.data.data.redirectUrl; // this is validated by the parent using this component
|
||||
} else {
|
||||
router.push(res.data.data.redirectUrl);
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
setError(formatAxiosError(e, "Error validating OIDC token"));
|
||||
} finally {
|
||||
|
@ -103,8 +102,12 @@ export default function ValidateOidcToken(props: ValidateOidcTokenParams) {
|
|||
<Alert variant="destructive" className="w-full">
|
||||
<AlertCircle className="h-5 w-5" />
|
||||
<AlertDescription className="flex flex-col space-y-2">
|
||||
<span>There was a problem connecting to {props.idp.name}. Please contact your administrator.</span>
|
||||
<span className="text-xs text-muted-foreground">{error}</span>
|
||||
<span>
|
||||
There was a problem connecting to{" "}
|
||||
{props.idp.name}. Please contact your
|
||||
administrator.
|
||||
</span>
|
||||
<span className="text-xs">{error}</span>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
|
|
@ -8,7 +8,7 @@ import {
|
|||
CardTitle
|
||||
} from "@/components/ui/card";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import LoginForm from "@app/components/LoginForm";
|
||||
import LoginForm, { LoginFormIDP } from "@app/components/LoginForm";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
|
@ -17,10 +17,12 @@ import { cleanRedirect } from "@app/lib/cleanRedirect";
|
|||
|
||||
type DashboardLoginFormProps = {
|
||||
redirect?: string;
|
||||
idps?: LoginFormIDP[];
|
||||
};
|
||||
|
||||
export default function DashboardLoginForm({
|
||||
redirect
|
||||
redirect,
|
||||
idps
|
||||
}: DashboardLoginFormProps) {
|
||||
const router = useRouter();
|
||||
// const api = createApiClient(useEnvContext());
|
||||
|
@ -51,12 +53,15 @@ export default function DashboardLoginForm({
|
|||
<h1 className="text-2xl font-bold mt-1">
|
||||
Welcome to Pangolin
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">Log in to get started</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Log in to get started
|
||||
</p>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<LoginForm
|
||||
redirect={redirect}
|
||||
idps={idps}
|
||||
onLogin={() => {
|
||||
if (redirect) {
|
||||
const safe = cleanRedirect(redirect);
|
||||
|
|
|
@ -6,6 +6,9 @@ import DashboardLoginForm from "./DashboardLoginForm";
|
|||
import { Mail } from "lucide-react";
|
||||
import { pullEnv } from "@app/lib/pullEnv";
|
||||
import { cleanRedirect } from "@app/lib/cleanRedirect";
|
||||
import db from "@server/db";
|
||||
import { idp } from "@server/db/schemas";
|
||||
import { LoginFormIDP } from "@app/components/LoginForm";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
|
@ -31,6 +34,12 @@ export default async function Page(props: {
|
|||
redirectUrl = cleanRedirect(searchParams.redirect as string);
|
||||
}
|
||||
|
||||
const idps = await db.select().from(idp);
|
||||
const loginIdps = idps.map((idp) => ({
|
||||
idpId: idp.idpId,
|
||||
name: idp.name
|
||||
})) as LoginFormIDP[];
|
||||
|
||||
return (
|
||||
<>
|
||||
{isInvite && (
|
||||
|
@ -48,7 +57,7 @@ export default async function Page(props: {
|
|||
</div>
|
||||
)}
|
||||
|
||||
<DashboardLoginForm redirect={redirectUrl} />
|
||||
<DashboardLoginForm redirect={redirectUrl} idps={loginIdps} />
|
||||
|
||||
{(!signUpDisabled || isInvite) && (
|
||||
<p className="text-center text-muted-foreground mt-4">
|
||||
|
|
|
@ -33,7 +33,7 @@ import { useRouter } from "next/navigation";
|
|||
import { Alert, AlertDescription } from "@app/components/ui/alert";
|
||||
import { formatAxiosError } from "@app/lib/api";
|
||||
import { AxiosResponse } from "axios";
|
||||
import LoginForm from "@app/components/LoginForm";
|
||||
import LoginForm, { LoginFormIDP } from "@app/components/LoginForm";
|
||||
import {
|
||||
AuthWithPasswordResponse,
|
||||
AuthWithWhitelistResponse
|
||||
|
@ -81,6 +81,7 @@ type ResourceAuthPortalProps = {
|
|||
id: number;
|
||||
};
|
||||
redirect: string;
|
||||
idps?: LoginFormIDP[];
|
||||
};
|
||||
|
||||
export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||
|
@ -490,6 +491,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
|||
className={`${numMethods <= 1 ? "mt-0" : ""}`}
|
||||
>
|
||||
<LoginForm
|
||||
idps={props.idps}
|
||||
redirect={props.redirect}
|
||||
onLogin={async () =>
|
||||
await handleSSOAuth()
|
||||
|
|
|
@ -13,6 +13,9 @@ import ResourceNotFound from "./ResourceNotFound";
|
|||
import ResourceAccessDenied from "./ResourceAccessDenied";
|
||||
import AccessToken from "./AccessToken";
|
||||
import { pullEnv } from "@app/lib/pullEnv";
|
||||
import { LoginFormIDP } from "@app/components/LoginForm";
|
||||
import db from "@server/db";
|
||||
import { idp } from "@server/db/schemas";
|
||||
|
||||
export default async function ResourceAuthPage(props: {
|
||||
params: Promise<{ resourceId: number }>;
|
||||
|
@ -84,7 +87,6 @@ export default async function ResourceAuthPage(props: {
|
|||
redirect(redirectUrl);
|
||||
}
|
||||
|
||||
|
||||
// convert the dashboard token into a resource session token
|
||||
let userIsUnauthorized = false;
|
||||
if (user && authInfo.sso) {
|
||||
|
@ -128,6 +130,12 @@ export default async function ResourceAuthPage(props: {
|
|||
);
|
||||
}
|
||||
|
||||
const idps = await db.select().from(idp);
|
||||
const loginIdps = idps.map((idp) => ({
|
||||
idpId: idp.idpId,
|
||||
name: idp.name
|
||||
})) as LoginFormIDP[];
|
||||
|
||||
return (
|
||||
<>
|
||||
{userIsUnauthorized && isSSOOnly ? (
|
||||
|
@ -148,6 +156,7 @@ export default async function ResourceAuthPage(props: {
|
|||
id: authInfo.resourceId
|
||||
}}
|
||||
redirect={redirectUrl}
|
||||
idps={loginIdps}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
@ -99,13 +99,16 @@ export function Layout({
|
|||
}
|
||||
/>
|
||||
</div>
|
||||
{!isAdminPage && (
|
||||
{!isAdminPage &&
|
||||
user.serverAdmin && (
|
||||
<div className="p-4 border-t">
|
||||
<Link
|
||||
href="/admin"
|
||||
className="flex items-center gap-3 text-muted-foreground hover:text-foreground transition-colors px-3 py-2 rounded-md w-full"
|
||||
onClick={() =>
|
||||
setIsMobileMenuOpen(false)
|
||||
setIsMobileMenuOpen(
|
||||
false
|
||||
)
|
||||
}
|
||||
>
|
||||
<Server className="h-4 w-4" />
|
||||
|
@ -234,7 +237,7 @@ export function Layout({
|
|||
)}
|
||||
>
|
||||
<main className="flex-1 overflow-y-auto p-3 md:p-6 w-full">
|
||||
<div className="container mx-auto max-w-12xl">
|
||||
<div className="container mx-auto max-w-12xl mb-12">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
|
|
|
@ -39,10 +39,17 @@ import Link from "next/link";
|
|||
import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp";
|
||||
import Image from "next/image";
|
||||
import { GenerateOidcUrlResponse } from "@server/routers/idp";
|
||||
import { Separator } from "./ui/separator";
|
||||
|
||||
export type LoginFormIDP = {
|
||||
idpId: number;
|
||||
name: string;
|
||||
};
|
||||
|
||||
type LoginFormProps = {
|
||||
redirect?: string;
|
||||
onLogin?: () => void | Promise<void>;
|
||||
idps?: LoginFormIDP[];
|
||||
};
|
||||
|
||||
const formSchema = z.object({
|
||||
|
@ -56,7 +63,7 @@ const mfaSchema = z.object({
|
|||
code: z.string().length(6, { message: "Invalid code" })
|
||||
});
|
||||
|
||||
export default function LoginForm({ redirect, onLogin }: LoginFormProps) {
|
||||
export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
||||
const router = useRouter();
|
||||
|
||||
const { env } = useEnvContext();
|
||||
|
@ -65,6 +72,7 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) {
|
|||
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const hasIdp = idps && idps.length > 0;
|
||||
|
||||
const [mfaRequested, setMfaRequested] = useState(false);
|
||||
|
||||
|
@ -207,16 +215,6 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) {
|
|||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
loginWithIdp(1);
|
||||
}}
|
||||
>
|
||||
OIDC Login
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
@ -303,6 +301,7 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) {
|
|||
)}
|
||||
|
||||
{!mfaRequested && (
|
||||
<>
|
||||
<Button
|
||||
type="submit"
|
||||
form="form"
|
||||
|
@ -313,6 +312,36 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) {
|
|||
<LockIcon className="w-4 h-4 mr-2" />
|
||||
Log In
|
||||
</Button>
|
||||
|
||||
{hasIdp && (
|
||||
<>
|
||||
<div className="relative my-4">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<Separator />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="px-2 bg-card text-muted-foreground">
|
||||
Or continue with
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{idps.map((idp) => (
|
||||
<Button
|
||||
key={idp.idpId}
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
loginWithIdp(idp.idpId);
|
||||
}}
|
||||
>
|
||||
{idp.name}
|
||||
</Button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{mfaRequested && (
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue