Compare commits

...

5 Commits

Author SHA1 Message Date
Fred KISSIE
3d13e9105c 🏷️ fix types 2026-07-03 22:33:58 +02:00
Fred KISSIE
289be30e6b show last used login idp in smart login form 2026-07-03 22:33:51 +02:00
Fred KISSIE
a74c0c227c 🌐 text 2026-07-03 21:05:53 +02:00
Fred KISSIE
05bf77da29 ♻️ remove cache on verifySession calls as it's already wrapped in cache 2026-07-03 21:05:47 +02:00
Fred KISSIE
bb4deb1ae9 remember last used idp in dashboard login form 2026-07-03 21:05:16 +02:00
8 changed files with 179 additions and 58 deletions

View File

@@ -1503,6 +1503,7 @@
"otpAuthDescription": "Enter the code from your authenticator app or one of your single-use backup codes.", "otpAuthDescription": "Enter the code from your authenticator app or one of your single-use backup codes.",
"otpAuthSubmit": "Submit Code", "otpAuthSubmit": "Submit Code",
"idpContinue": "Or continue with", "idpContinue": "Or continue with",
"idpLastUsed": "Last used",
"otpAuthBack": "Back to Password", "otpAuthBack": "Back to Password",
"navbar": "Navigation Menu", "navbar": "Navigation Menu",
"navbarDescription": "Main navigation menu for the application", "navbarDescription": "Main navigation menu for the application",

View File

@@ -16,8 +16,11 @@ import LoginCardHeader from "@app/components/LoginCardHeader";
import { priv } from "@app/lib/api"; import { priv } from "@app/lib/api";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { LoginFormIDP } from "@app/components/LoginForm"; 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 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 = { export const metadata: Metadata = {
title: "Log In" title: "Log In"
@@ -29,8 +32,9 @@ export default async function Page(props: {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>; searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}) { }) {
const searchParams = await props.searchParams; const searchParams = await props.searchParams;
const getUser = cache(verifySession); const user = await verifySession({ skipCheckVerifyEmail: true });
const user = await getUser({ skipCheckVerifyEmail: true });
const lastUsedIdpCookie = (await cookies()).get(LAST_USED_IDP_COOKIE_NAME);
const isInvite = searchParams?.redirect?.includes("/invite"); const isInvite = searchParams?.redirect?.includes("/invite");
const forceLoginParam = searchParams?.forceLogin; const forceLoginParam = searchParams?.forceLogin;
@@ -85,19 +89,47 @@ export default async function Page(props: {
(build === "enterprise" && env.app.identityProviderMode === "org"); (build === "enterprise" && env.app.identityProviderMode === "org");
let loginIdps: LoginFormIDP[] = []; let loginIdps: LoginFormIDP[] = [];
let lastUsedIdpForSmartLogin: (LoginFormIDP & { orgId?: string }) | null =
null;
if (!useSmartLogin) { if (!useSmartLogin) {
// Load IdPs for DashboardLoginForm (OSS or org-only IdP mode) // Load IdPs for DashboardLoginForm (OSS or org-only IdP mode)
if (build === "oss" || env.app.identityProviderMode !== "org") { if (build === "oss" || env.app.identityProviderMode !== "org") {
const idpsRes = await cache( const idpsRes =
async () => await priv.get<AxiosResponse<ListIdpsResponse>>("/idp");
await priv.get<AxiosResponse<ListIdpsResponse>>("/idp")
)();
loginIdps = idpsRes.data.data.idps.map((idp) => ({ loginIdps = idpsRes.data.data.idps.map((idp) => ({
idpId: idp.idpId, idpId: idp.idpId,
name: idp.name, name: idp.name,
variant: idp.type variant: idp.type
})) as LoginFormIDP[]; })) 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(); const t = await getTranslations();
@@ -160,6 +192,7 @@ export default async function Page(props: {
redirect={redirectUrl} redirect={redirectUrl}
forceLogin={forceLogin} forceLogin={forceLogin}
defaultUser={defaultUser} defaultUser={defaultUser}
lastUsedIdp={lastUsedIdpForSmartLogin}
orgSignIn={ orgSignIn={
!isInvite && !isInvite &&
(build === "saas" || (build === "saas" ||

View File

@@ -5,7 +5,6 @@ import UserProvider from "@app/providers/UserProvider";
import { ListUserOrgsResponse } from "@server/routers/org"; import { ListUserOrgsResponse } from "@server/routers/org";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { cache } from "react";
import OrganizationLanding from "@app/components/OrganizationLanding"; import OrganizationLanding from "@app/components/OrganizationLanding";
import { pullEnv } from "@app/lib/pullEnv"; import { pullEnv } from "@app/lib/pullEnv";
import { cleanRedirect } from "@app/lib/cleanRedirect"; import { cleanRedirect } from "@app/lib/cleanRedirect";
@@ -13,7 +12,6 @@ import { Layout } from "@app/components/Layout";
import RedirectToOrg from "@app/components/RedirectToOrg"; import RedirectToOrg from "@app/components/RedirectToOrg";
import { InitialSetupCompleteResponse } from "@server/routers/auth"; import { InitialSetupCompleteResponse } from "@server/routers/auth";
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { build } from "@server/build";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -27,8 +25,7 @@ export default async function Page(props: {
const env = pullEnv(); const env = pullEnv();
const getUser = cache(verifySession); const user = await verifySession({ skipCheckVerifyEmail: true });
const user = await getUser({ skipCheckVerifyEmail: true });
let complete = false; let complete = false;
try { try {

View File

@@ -1,26 +1,25 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { generateOidcUrlProxy } from "@app/actions/server";
import { Button } from "@app/components/ui/button";
import { Alert, AlertDescription } from "@app/components/ui/alert";
import { useTranslations } from "next-intl";
import IdpTypeIcon from "@app/components/IdpTypeIcon"; import IdpTypeIcon from "@app/components/IdpTypeIcon";
import { import { Alert, AlertDescription } from "@app/components/ui/alert";
generateOidcUrlProxy, import { Button } from "@app/components/ui/button";
type GenerateOidcUrlResponse import { cleanRedirect } from "@app/lib/cleanRedirect";
} from "@app/actions/server"; import { LAST_USED_IDP_COOKIE_NAME } from "@app/lib/consts";
import { setClientCookie } from "@app/lib/setClientCookie";
import { useTranslations } from "next-intl";
import { import {
redirect as redirectTo, redirect as redirectTo,
useParams, useRouter,
useSearchParams useSearchParams
} from "next/navigation"; } from "next/navigation";
import { useRouter } from "next/navigation"; import { useEffect, useState, useTransition } from "react";
import { cleanRedirect } from "@app/lib/cleanRedirect";
export type LoginFormIDP = { export type LoginFormIDP = {
idpId: number; idpId: number;
name: string; name: string;
variant?: string; variant?: string;
lastUsed?: boolean;
}; };
type IdpLoginButtonsProps = { type IdpLoginButtonsProps = {
@@ -35,7 +34,6 @@ export default function IdpLoginButtons({
orgId orgId
}: IdpLoginButtonsProps) { }: IdpLoginButtonsProps) {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const t = useTranslations(); const t = useTranslations();
const params = useSearchParams(); const params = useSearchParams();
@@ -52,10 +50,22 @@ export default function IdpLoginButtons({
} }
}, []); }, []);
const [loading, startTransition] = useTransition();
async function loginWithIdp(idpId: number) { async function loginWithIdp(idpId: number) {
setLoading(true);
setError(null); setError(null);
setClientCookie(
LAST_USED_IDP_COOKIE_NAME,
JSON.stringify({
orgId,
idpId
}),
{
sameSite: "Lax"
}
);
let redirectToUrl: string | undefined; let redirectToUrl: string | undefined;
try { try {
console.log("generating", idpId, redirect || "/", orgId); console.log("generating", idpId, redirect || "/", orgId);
@@ -68,7 +78,6 @@ export default function IdpLoginButtons({
if (response.error) { if (response.error) {
setError(response.message); setError(response.message);
setLoading(false);
return; return;
} }
@@ -84,7 +93,6 @@ export default function IdpLoginButtons({
"An unexpected error occurred. Please try again." "An unexpected error occurred. Please try again."
}) })
); );
setLoading(false);
} }
if (redirectToUrl) { if (redirectToUrl) {
@@ -124,20 +132,38 @@ export default function IdpLoginButtons({
idp.variant || idp.name.toLowerCase(); idp.variant || idp.name.toLowerCase();
return ( return (
<Button <div
className="w-full relative"
key={idp.idpId} 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} /> <Button
<span>{idp.name}</span> key={idp.idpId}
</Button> 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>
); );
})} })}
</> </>

View File

@@ -30,10 +30,7 @@ import Link from "next/link";
import { GenerateOidcUrlResponse } from "@server/routers/idp"; import { GenerateOidcUrlResponse } from "@server/routers/idp";
import { Separator } from "./ui/separator"; import { Separator } from "./ui/separator";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { import { generateOidcUrlProxy, loginProxy } from "@app/actions/server";
generateOidcUrlProxy,
loginProxy
} from "@app/actions/server";
import { redirect as redirectTo } from "next/navigation"; import { redirect as redirectTo } from "next/navigation";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import IdpTypeIcon from "@app/components/IdpTypeIcon"; import IdpTypeIcon from "@app/components/IdpTypeIcon";
@@ -41,11 +38,13 @@ import IdpTypeIcon from "@app/components/IdpTypeIcon";
import { loadReoScript } from "reodotdev"; import { loadReoScript } from "reodotdev";
import { build } from "@server/build"; import { build } from "@server/build";
import MfaInputForm from "@app/components/MfaInputForm"; import MfaInputForm from "@app/components/MfaInputForm";
import { useLocalStorage } from "@app/hooks/useLocalStorage";
export type LoginFormIDP = { export type LoginFormIDP = {
idpId: number; idpId: number;
name: string; name: string;
variant?: string; variant?: string;
lastUsed?: boolean;
}; };
type LoginFormProps = { type LoginFormProps = {
@@ -105,7 +104,6 @@ export default function LoginForm({
} }
}, []); }, []);
const formSchema = z.object({ const formSchema = z.object({
email: z.string().email({ message: t("emailInvalid") }), email: z.string().email({ message: t("emailInvalid") }),
password: z.string().min(8, { message: t("passwordRequirementsChars") }) password: z.string().min(8, { message: t("passwordRequirementsChars") })
@@ -130,6 +128,10 @@ export default function LoginForm({
} }
}); });
const [lastUsedIdpId, setLastUsedIdpId] = useLocalStorage<string | null>(
"login:last-used-idp",
null
);
async function onSubmit(values: any) { async function onSubmit(values: any) {
const { email, password } = form.getValues(); const { email, password } = form.getValues();
@@ -179,8 +181,7 @@ export default function LoginForm({
if (data.useSecurityKey) { if (data.useSecurityKey) {
setError( setError(
t("securityKeyRequired", { t("securityKeyRequired", {
defaultValue: defaultValue: "Please use your security key to sign in."
"Please use your security key to sign in."
}) })
); );
return; return;
@@ -242,6 +243,8 @@ export default function LoginForm({
async function loginWithIdp(idpId: number) { async function loginWithIdp(idpId: number) {
let redirectUrl: string | undefined; let redirectUrl: string | undefined;
setLastUsedIdpId(idpId.toString());
try { try {
const data = await generateOidcUrlProxy( const data = await generateOidcUrlProxy(
idpId, idpId,
@@ -356,7 +359,6 @@ export default function LoginForm({
)} )}
<div className="space-y-4"> <div className="space-y-4">
{!mfaRequested && ( {!mfaRequested && (
<> <>
<SecurityKeyAuthButton <SecurityKeyAuthButton
@@ -385,25 +387,41 @@ export default function LoginForm({
idp.variant || idp.name.toLowerCase(); idp.variant || idp.name.toLowerCase();
return ( return (
<Button <div
className="w-full relative"
key={idp.idpId} 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} /> <Button
<span>{idp.name}</span> key={idp.idpId}
</Button> 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>
</div> </div>
); );

View File

@@ -27,6 +27,8 @@ import UserProfileCard from "@app/components/UserProfileCard";
import SecurityKeyAuthButton from "@app/components/SecurityKeyAuthButton"; import SecurityKeyAuthButton from "@app/components/SecurityKeyAuthButton";
import { Separator } from "@app/components/ui/separator"; import { Separator } from "@app/components/ui/separator";
import OrgSignInLink from "@app/components/OrgSignInLink"; import OrgSignInLink from "@app/components/OrgSignInLink";
import type { LoginFormIDP } from "./LoginForm";
import IdpLoginButtons from "./IdpLoginButtons";
const identifierSchema = z.object({ const identifierSchema = z.object({
identifier: z.string().min(1, "Username or email is required") identifier: z.string().min(1, "Username or email is required")
@@ -53,6 +55,7 @@ type SmartLoginFormProps = {
forceLogin?: boolean; forceLogin?: boolean;
defaultUser?: string; defaultUser?: string;
orgSignIn?: OrgSignInConfig; orgSignIn?: OrgSignInConfig;
lastUsedIdp?: (LoginFormIDP & { orgId?: string }) | null;
}; };
type ViewState = type ViewState =
@@ -89,7 +92,8 @@ export default function SmartLoginForm({
redirect, redirect,
forceLogin, forceLogin,
defaultUser, defaultUser,
orgSignIn orgSignIn,
lastUsedIdp
}: SmartLoginFormProps) { }: SmartLoginFormProps) {
const router = useRouter(); const router = useRouter();
const { env } = useEnvContext(); const { env } = useEnvContext();
@@ -294,6 +298,15 @@ export default function SmartLoginForm({
</span> </span>
</div> </div>
</div> </div>
{lastUsedIdp && (
<IdpLoginButtons
idps={[lastUsedIdp]}
orgId={lastUsedIdp.orgId}
redirect={redirect}
/>
)}
<OrgSignInLink <OrgSignInLink
href={orgSignIn.href} href={orgSignIn.href}
linkText={orgSignIn.linkText} linkText={orgSignIn.linkText}

1
src/lib/consts.ts Normal file
View File

@@ -0,0 +1 @@
export const LAST_USED_IDP_COOKIE_NAME = "p__last_used_idp";

View File

@@ -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;
}