Merge branch 'main' into fix-idp-options-handling

This commit is contained in:
Max Peintner
2025-06-05 13:12:04 +02:00
16 changed files with 418 additions and 50 deletions

View File

@@ -8,6 +8,17 @@
"addAnother": "Ein weiteres Konto hinzufügen",
"noResults": "Keine Konten gefunden"
},
"logout": {
"title": "Logout",
"description": "Wählen Sie den Account aus, das Sie entfernen möchten",
"noResults": "Keine Konten gefunden",
"clear": "Session beenden",
"verifiedAt": "Zuletzt aktiv: {time}",
"success": {
"title": "Logout erfolgreich",
"description": "Sie haben sich erfolgreich abgemeldet."
}
},
"loginname": {
"title": "Willkommen zurück!",
"description": "Geben Sie Ihre Anmeldedaten ein.",

View File

@@ -8,6 +8,17 @@
"addAnother": "Add another account",
"noResults": "No accounts found"
},
"logout": {
"title": "Logout",
"description": "Click an account to end the session",
"noResults": "No accounts found",
"clear": "End Session",
"verifiedAt": "Last active: {time}",
"success": {
"title": "Logout successful",
"description": "You have successfully logged out."
}
},
"loginname": {
"title": "Welcome back!",
"description": "Enter your login data.",

View File

@@ -8,6 +8,17 @@
"addAnother": "Agregar otra cuenta",
"noResults": "No se encontraron cuentas"
},
"logout": {
"title": "Cerrar sesión",
"description": "Selecciona la cuenta que deseas eliminar",
"noResults": "No se encontraron cuentas",
"clear": "Eliminar sesión",
"verifiedAt": "Última actividad: {time}",
"success": {
"title": "Cierre de sesión exitoso",
"description": "Has cerrado sesión correctamente."
}
},
"loginname": {
"title": "¡Bienvenido de nuevo!",
"description": "Introduce tus datos de acceso.",

View File

@@ -8,6 +8,17 @@
"addAnother": "Aggiungi un altro account",
"noResults": "Nessun account trovato"
},
"logout": {
"title": "Esci",
"description": "Seleziona l'account che desideri uscire",
"noResults": "Nessun account trovato",
"clear": "Elimina sessione",
"verifiedAt": "Ultima attività: {time}",
"success": {
"title": "Uscita riuscita",
"description": "Hai effettuato l'uscita con successo."
}
},
"loginname": {
"title": "Bentornato!",
"description": "Inserisci i tuoi dati di accesso.",

View File

@@ -8,6 +8,17 @@
"addAnother": "Dodaj kolejne konto",
"noResults": "Nie znaleziono kont"
},
"logout": {
"title": "Wyloguj się",
"description": "Wybierz konto, które chcesz usunąć",
"noResults": "Nie znaleziono kont",
"clear": "Usuń sesję",
"verifiedAt": "Ostatnia aktywność: {time}",
"success": {
"title": "Wylogowanie udane",
"description": "Pomyślnie się wylogowałeś."
}
},
"loginname": {
"title": "Witamy ponownie!",
"description": "Wprowadź dane logowania.",

View File

@@ -8,6 +8,17 @@
"addAnother": "Добавить другой аккаунт",
"noResults": "Аккаунты не найдены"
},
"logout": {
"title": "Выход",
"description": "Выберите аккаунт, который хотите удалить",
"noResults": "Аккаунты не найдены",
"clear": "Удалить сессию",
"verifiedAt": "Последняя активность: {time}",
"success": {
"title": "Выход выполнен успешно",
"description": "Вы успешно вышли из системы."
}
},
"loginname": {
"title": "С возвращением!",
"description": "Введите свои данные для входа.",

View File

@@ -8,6 +8,17 @@
"addAnother": "添加另一个账户",
"noResults": "未找到账户"
},
"logout": {
"title": "注销",
"description": "选择您想注销的账户",
"noResults": "未找到账户",
"clear": "注销会话",
"verifiedAt": "最后活动时间:{time}",
"success": {
"title": "注销成功",
"description": "您已成功注销。"
}
},
"loginname": {
"title": "欢迎回来!",
"description": "请输入您的登录信息。",

View File

@@ -0,0 +1,84 @@
import { DynamicTheme } from "@/components/dynamic-theme";
import { SessionsClearList } from "@/components/sessions-clear-list";
import { getAllSessionCookieIds } from "@/lib/cookies";
import { getServiceUrlFromHeaders } from "@/lib/service-url";
import {
getBrandingSettings,
getDefaultOrg,
listSessions,
} from "@/lib/zitadel";
import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb";
import { getLocale, getTranslations } from "next-intl/server";
import { headers } from "next/headers";
async function loadSessions({ serviceUrl }: { serviceUrl: string }) {
const ids: (string | undefined)[] = await getAllSessionCookieIds();
if (ids && ids.length) {
const response = await listSessions({
serviceUrl,
ids: ids.filter((id) => !!id) as string[],
});
return response?.sessions ?? [];
} else {
console.info("No session cookie found.");
return [];
}
}
export default async function Page(props: {
searchParams: Promise<Record<string | number | symbol, string | undefined>>;
}) {
const searchParams = await props.searchParams;
const locale = getLocale();
const t = await getTranslations({ locale, namespace: "logout" });
const organization = searchParams?.organization;
const postLogoutRedirectUri = searchParams?.post_logout_redirect_uri;
const logoutHint = searchParams?.logout_hint;
const UILocales = searchParams?.ui_locales; // TODO implement with new translation service
const _headers = await headers();
const { serviceUrl } = getServiceUrlFromHeaders(_headers);
let defaultOrganization;
if (!organization) {
const org: Organization | null = await getDefaultOrg({
serviceUrl,
});
if (org) {
defaultOrganization = org.id;
}
}
let sessions = await loadSessions({ serviceUrl });
const branding = await getBrandingSettings({
serviceUrl,
organization: organization ?? defaultOrganization,
});
const params = new URLSearchParams();
if (organization) {
params.append("organization", organization);
}
return (
<DynamicTheme branding={branding}>
<div className="flex flex-col items-center space-y-4">
<h1>{t("title")}</h1>
<p className="ztdl-p mb-6 block">{t("description")}</p>
<div className="flex flex-col w-full space-y-2">
<SessionsClearList
sessions={sessions}
logoutHint={logoutHint}
postLogoutRedirectUri={postLogoutRedirectUri}
organization={organization ?? defaultOrganization}
/>
</div>
</div>
</DynamicTheme>
);
}

View File

@@ -0,0 +1,41 @@
import { DynamicTheme } from "@/components/dynamic-theme";
import { getServiceUrlFromHeaders } from "@/lib/service-url";
import { getBrandingSettings, getDefaultOrg } from "@/lib/zitadel";
import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb";
import { getLocale, getTranslations } from "next-intl/server";
import { headers } from "next/headers";
export default async function Page(props: { searchParams: Promise<any> }) {
const searchParams = await props.searchParams;
const locale = getLocale();
const t = await getTranslations({ locale, namespace: "logout" });
const _headers = await headers();
const { serviceUrl } = getServiceUrlFromHeaders(_headers);
const { login_hint, organization } = searchParams;
let defaultOrganization;
if (!organization) {
const org: Organization | null = await getDefaultOrg({
serviceUrl,
});
if (org) {
defaultOrganization = org.id;
}
}
const branding = await getBrandingSettings({
serviceUrl,
organization,
});
return (
<DynamicTheme branding={branding}>
<div className="flex flex-col items-center space-y-4">
<h1>{t("success.title")}</h1>
<p className="ztdl-p mb-6 block">{t("success.description")}</p>
</div>
</DynamicTheme>
);
}

View File

@@ -1,35 +1,16 @@
import { DynamicTheme } from "@/components/dynamic-theme";
import { UserAvatar } from "@/components/user-avatar";
import { getSessionCookieById } from "@/lib/cookies";
import { getServiceUrlFromHeaders } from "@/lib/service-url";
import { loadMostRecentSession } from "@/lib/session";
import {
getBrandingSettings,
getLoginSettings,
getSession,
getUserByID,
} from "@/lib/zitadel";
import { HumanUser, User } from "@zitadel/proto/zitadel/user/v2/user_pb";
import { getLocale, getTranslations } from "next-intl/server";
import { headers } from "next/headers";
async function loadSessionById(
serviceUrl: string,
sessionId: string,
organization?: string,
) {
const recent = await getSessionCookieById({ sessionId, organization });
return getSession({
serviceUrl,
sessionId: recent.id,
sessionToken: recent.token,
}).then((response) => {
if (response?.session) {
return response.session;
}
});
}
export default async function Page(props: { searchParams: Promise<any> }) {
const searchParams = await props.searchParams;
const locale = getLocale();

View File

@@ -43,7 +43,7 @@ export function LanguageSwitcher() {
>
{selected.name}
<ChevronDownIcon
className="group pointer-events-none absolute top-2.5 right-2.5 size-4 fill-white/60"
className="group pointer-events-none absolute top-2.5 right-2.5 size-4"
aria-hidden="true"
/>
</ListboxButton>
@@ -61,7 +61,7 @@ export function LanguageSwitcher() {
value={lang}
className="group flex cursor-default items-center gap-2 rounded-lg py-1.5 px-3 select-none data-[focus]:bg-black/10 dark:data-[focus]:bg-white/10"
>
<CheckIcon className="invisible size-4 fill-white group-data-[selected]:visible" />
<CheckIcon className="invisible size-4 group-data-[selected]:visible" />
<div className="text-sm/6 text-black dark:text-white">
{lang.name}
</div>

View File

@@ -0,0 +1,103 @@
"use client";
import { clearSession } from "@/lib/server/session";
import { timestampDate } from "@zitadel/client";
import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb";
import moment from "moment";
import { useLocale, useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { Avatar } from "./avatar";
import { isSessionValid } from "./session-item";
export function SessionClearItem({
session,
reload,
}: {
session: Session;
reload: () => void;
}) {
const t = useTranslations("logout");
const currentLocale = useLocale();
moment.locale(currentLocale === "zh" ? "zh-cn" : currentLocale);
const [loading, setLoading] = useState<boolean>(false);
async function clearSessionId(id: string) {
setLoading(true);
const response = await clearSession({
sessionId: id,
})
.catch((error) => {
setError(error.message);
return;
})
.finally(() => {
setLoading(false);
});
return response;
}
const { valid, verifiedAt } = isSessionValid(session);
const [error, setError] = useState<string | null>(null);
const router = useRouter();
return (
<button
onClick={async () => {
clearSessionId(session.id).then(() => {
reload();
});
}}
className="group flex flex-row items-center bg-background-light-400 dark:bg-background-dark-400 border border-divider-light hover:shadow-lg dark:hover:bg-white/10 py-2 px-4 rounded-md transition-all"
>
<div className="pr-4">
<Avatar
size="small"
loginName={session.factors?.user?.loginName as string}
name={session.factors?.user?.displayName ?? ""}
/>
</div>
<div className="flex flex-col items-start overflow-hidden">
<span className="">{session.factors?.user?.displayName}</span>
<span className="text-xs opacity-80 text-ellipsis">
{session.factors?.user?.loginName}
</span>
{valid ? (
<span className="text-xs opacity-80 text-ellipsis">
{verifiedAt &&
t("verfiedAt", {
time: moment(timestampDate(verifiedAt)).fromNow(),
})}
</span>
) : (
verifiedAt && (
<span className="text-xs opacity-80 text-ellipsis">
expired{" "}
{session.expirationDate &&
moment(timestampDate(session.expirationDate)).fromNow()}
</span>
)
)}
</div>
<span className="flex-grow"></span>
<div className="relative flex flex-row items-center">
<div className="mr-6 px-2 py-[2px] text-xs hidden group-hover:block transition-all text-warn-light-500 dark:text-warn-dark-500 bg-[#ff0000]/10 dark:bg-[#ff0000]/10 rounded-full flex items-center justify-center">
{t("clear")}
</div>
{valid ? (
<div className="absolute h-2 w-2 bg-green-500 rounded-full mx-2 transform right-0 transition-all"></div>
) : (
<div className="absolute h-2 w-2 bg-red-500 rounded-full mx-2 transform right-0 transition-all"></div>
)}
</div>
</button>
);
}

View File

@@ -1,7 +1,7 @@
"use client";
import { sendLoginname } from "@/lib/server/loginname";
import { cleanupSession, continueWithSession } from "@/lib/server/session";
import { clearSession, continueWithSession } from "@/lib/server/session";
import { XCircleIcon } from "@heroicons/react/24/outline";
import { Timestamp, timestampDate } from "@zitadel/client";
import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb";
@@ -43,9 +43,9 @@ export function SessionItem({
const [loading, setLoading] = useState<boolean>(false);
async function clearSession(id: string) {
async function clearSessionId(id: string) {
setLoading(true);
const response = await cleanupSession({
const response = await clearSession({
sessionId: id,
})
.catch((error) => {
@@ -145,7 +145,7 @@ export function SessionItem({
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
clearSession(session.id).then(() => {
clearSessionId(session.id).then(() => {
reload();
});
}}

View File

@@ -0,0 +1,106 @@
"use client";
import { clearSession } from "@/lib/server/session";
import { timestampDate } from "@zitadel/client";
import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb";
import { useTranslations } from "next-intl";
import { redirect, useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { Alert, AlertType } from "./alert";
import { SessionClearItem } from "./session-clear-item";
type Props = {
sessions: Session[];
postLogoutRedirectUri?: string;
logoutHint?: string;
organization?: string;
};
export function SessionsClearList({
sessions,
logoutHint,
postLogoutRedirectUri,
organization,
}: Props) {
const t = useTranslations("logout");
const [list, setList] = useState<Session[]>(sessions);
const router = useRouter();
async function clearHintedSession() {
console.log("Clearing session for login hint:", logoutHint);
// If a login hint is provided, we logout that specific session
const sessionIdToBeCleared = sessions.find((session) => {
return session.factors?.user?.loginName === logoutHint;
})?.id;
if (sessionIdToBeCleared) {
const clearSessionResponse = await clearSession({
sessionId: sessionIdToBeCleared,
}).catch((error) => {
console.error("Error clearing session:", error);
return;
});
if (!clearSessionResponse) {
console.error("Failed to clear session for login hint:", logoutHint);
}
if (postLogoutRedirectUri) {
return redirect(postLogoutRedirectUri);
}
const params = new URLSearchParams();
if (organization) {
params.set("organization", organization);
}
return router.push("/logout/success?" + params);
} else {
console.warn(`No session found for login hint: ${logoutHint}`);
}
}
useEffect(() => {
if (logoutHint) {
clearHintedSession();
}
}, []);
return sessions ? (
<div className="flex flex-col space-y-2">
{list
.filter((session) => session?.factors?.user?.loginName)
// sort by change date descending
.sort((a, b) => {
const dateA = a.changeDate
? timestampDate(a.changeDate).getTime()
: 0;
const dateB = b.changeDate
? timestampDate(b.changeDate).getTime()
: 0;
return dateB - dateA;
})
// TODO: add sorting to move invalid sessions to the bottom
.map((session, index) => {
return (
<SessionClearItem
session={session}
reload={() => {
setList(list.filter((s) => s.id !== session.id));
if (postLogoutRedirectUri) {
router.push(postLogoutRedirectUri);
}
}}
key={"session-" + index}
/>
);
})}
{list.length === 0 && (
<Alert type={AlertType.INFO}>{t("noResults")}</Alert>
)}
</div>
) : (
<Alert>{t("noResults")}</Alert>
);
}

View File

@@ -141,7 +141,7 @@ export async function removeSessionFromCookie<T>({
session: SessionCookie<T>;
cleanup?: boolean;
sameSite?: boolean | "lax" | "strict" | "none" | undefined;
}): Promise<any> {
}) {
const cookiesList = await cookies();
const stringifiedCookie = cookiesList.get("sessions");

View File

@@ -202,30 +202,6 @@ export async function clearSession(options: ClearSessionOptions) {
const { sessionId } = options;
const session = await getSessionCookieById({ sessionId });
const deletedSession = await deleteSession({
serviceUrl,
sessionId: session.id,
sessionToken: session.token,
});
const securitySettings = await getSecuritySettings({ serviceUrl });
const sameSite = securitySettings?.embeddedIframe?.enabled ? "none" : true;
if (deletedSession) {
return removeSessionFromCookie({ session, sameSite });
}
}
type CleanupSessionCommand = {
sessionId: string;
};
export async function cleanupSession({ sessionId }: CleanupSessionCommand) {
const _headers = await headers();
const { serviceUrl } = getServiceUrlFromHeaders(_headers);
const sessionCookie = await getSessionCookieById({ sessionId });
const deleteResponse = await deleteSession({