logout page

This commit is contained in:
Max Peintner
2025-04-28 09:33:29 +02:00
parent e8fe9848fd
commit a5dc44c01c
11 changed files with 172 additions and 11 deletions

View File

@@ -10,7 +10,8 @@
},
"logout": {
"title": "Logout",
"description": "Wählen Sie den Account aus, das Sie entfernen möchten"
"description": "Wählen Sie den Account aus, das Sie entfernen möchten",
"noResults": "Keine Konten gefunden"
},
"loginname": {
"title": "Willkommen zurück!",

View File

@@ -10,7 +10,8 @@
},
"logout": {
"title": "Logout",
"description": "Select the account you want to clear"
"description": "Click the accounts you want to clear",
"noResults": "No accounts found"
},
"loginname": {
"title": "Welcome back!",

View File

@@ -10,7 +10,8 @@
},
"logout": {
"title": "Cerrar sesión",
"description": "Selecciona la cuenta que deseas eliminar"
"description": "Selecciona la cuenta que deseas eliminar",
"noResults": "No se encontraron cuentas"
},
"loginname": {
"title": "¡Bienvenido de nuevo!",

View File

@@ -10,7 +10,8 @@
},
"logout": {
"title": "Esci",
"description": "Seleziona l'account che desideri uscire"
"description": "Seleziona l'account che desideri uscire",
"noResults": "Nessun account trovato"
},
"loginname": {
"title": "Bentornato!",

View File

@@ -10,7 +10,8 @@
},
"logout": {
"title": "Wyloguj się",
"description": "Wybierz konto, które chcesz usunąć"
"description": "Wybierz konto, które chcesz usunąć",
"noResults": "Nie znaleziono kont"
},
"loginname": {
"title": "Witamy ponownie!",

View File

@@ -10,7 +10,8 @@
},
"logout": {
"title": "Выход",
"description": "Выберите аккаунт, который хотите удалить"
"description": "Выберите аккаунт, который хотите удалить",
"noResults": "Аккаунты не найдены"
},
"loginname": {
"title": "С возвращением!",

View File

@@ -10,7 +10,8 @@
},
"logout": {
"title": "注销",
"description": "选择您想注销的账户"
"description": "选择您想注销的账户",
"noResults": "未找到账户"
},
"loginname": {
"title": "欢迎回来!",

View File

@@ -1,5 +1,5 @@
import { DynamicTheme } from "@/components/dynamic-theme";
import { SessionsList } from "@/components/sessions-list";
import { SessionsClearList } from "@/components/sessions-clear-list";
import { getAllSessionCookieIds } from "@/lib/cookies";
import { getServiceUrlFromHeaders } from "@/lib/service";
import {
@@ -73,7 +73,7 @@ export default async function Page(props: {
<p className="ztdl-p mb-6 block">{t("description")}</p>
<div className="flex flex-col w-full space-y-2">
<SessionsList sessions={sessions} requestId={requestId} />
<SessionsClearList sessions={sessions} requestId={requestId} />
</div>
</div>
</DynamicTheme>

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,102 @@
"use client";
import { cleanupSession } 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,
requestId,
}: {
session: Session;
reload: () => void;
requestId?: string;
}) {
const t = useTranslations("logout");
const currentLocale = useLocale();
moment.locale(currentLocale === "zh" ? "zh-cn" : currentLocale);
const [loading, setLoading] = useState<boolean>(false);
async function clearSession(id: string) {
setLoading(true);
const response = await cleanupSession({
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 () => {
clearSession(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 && 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

@@ -0,0 +1,52 @@
"use client";
import { timestampDate } from "@zitadel/client";
import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb";
import { useTranslations } from "next-intl";
import { useState } from "react";
import { Alert, AlertType } from "./alert";
import { SessionClearItem } from "./session-clear-item";
type Props = {
sessions: Session[];
requestId?: string;
};
export function SessionsClearList({ sessions, requestId }: Props) {
const t = useTranslations("logout");
const [list, setList] = useState<Session[]>(sessions);
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}
requestId={requestId}
reload={() => {
setList(list.filter((s) => s.id !== session.id));
}}
key={"session-" + index}
/>
);
})}
{list.length === 0 && (
<Alert type={AlertType.INFO}>{t("noResults")}</Alert>
)}
</div>
) : (
<Alert>{t("noResults")}</Alert>
);
}