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.",
"otpAuthSubmit": "Submit Code",
"idpContinue": "Or continue with",
"idpLastUsed": "Last used",
"otpAuthBack": "Back to Password",
"navbar": "Navigation Menu",
"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 { 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" ||

View File

@@ -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";
@@ -27,8 +25,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 {

View File

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

View File

@@ -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,6 +128,10 @@ 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();
@@ -179,8 +181,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 +243,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 +359,6 @@ export default function LoginForm({
)}
<div className="space-y-4">
{!mfaRequested && (
<>
<SecurityKeyAuthButton
@@ -385,25 +387,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>
);

View File

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

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