mirror of
https://github.com/fosrl/pangolin.git
synced 2026-07-14 01:31:50 +02:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1580b7abff |
@@ -1517,7 +1517,6 @@
|
|||||||
"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",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db, logsDb, statusHistory } from "@server/db";
|
import { db, logsDb, statusHistory } from "@server/db";
|
||||||
import { and, eq, gte, lt, asc, desc } from "drizzle-orm";
|
import { and, eq, gte, lt, asc, desc, inArray } from "drizzle-orm";
|
||||||
import { regionalCache as cache } from "#dynamic/lib/cache";
|
import { regionalCache as cache } from "#dynamic/lib/cache";
|
||||||
|
|
||||||
const STATUS_HISTORY_CACHE_TTL = 60; // seconds
|
const STATUS_HISTORY_CACHE_TTL = 60; // seconds
|
||||||
@@ -273,3 +273,81 @@ export function computeBuckets(
|
|||||||
|
|
||||||
return { buckets, totalDowntime };
|
return { buckets, totalDowntime };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BatchedStatusHistoryResponse {
|
||||||
|
entityType: string;
|
||||||
|
entityIds: number[];
|
||||||
|
days: StatusHistoryDayBucket[];
|
||||||
|
overallUptimePercent: number;
|
||||||
|
totalDowntimeSeconds: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getBatchedStatusHistory(
|
||||||
|
entityType: string,
|
||||||
|
entityIds: number[],
|
||||||
|
days: number
|
||||||
|
): Promise<BatchedStatusHistoryResponse> {
|
||||||
|
// const cacheKey = statusHistoryCacheKey(entityType, entityId, days);
|
||||||
|
// const cached = await cache.get<StatusHistoryResponse>(cacheKey);
|
||||||
|
// if (cached !== undefined) {
|
||||||
|
// return cached;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// Anchor to UTC midnight so the query window aligns with stable calendar days
|
||||||
|
const utcToday = new Date();
|
||||||
|
utcToday.setUTCHours(0, 0, 0, 0);
|
||||||
|
const todayMidnightSec = Math.floor(utcToday.getTime() / 1000);
|
||||||
|
const startSec = todayMidnightSec - days * 86400;
|
||||||
|
|
||||||
|
const events = await logsDb
|
||||||
|
.select()
|
||||||
|
.from(statusHistory)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(statusHistory.entityType, entityType),
|
||||||
|
inArray(statusHistory.entityId, entityIds),
|
||||||
|
gte(statusHistory.timestamp, startSec)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.orderBy(asc(statusHistory.timestamp));
|
||||||
|
|
||||||
|
// Fetch the last known state before the window so that entities that
|
||||||
|
// haven't changed status recently still show the correct status rather
|
||||||
|
// than appearing as "no_data".
|
||||||
|
const [lastKnownEvent] = await logsDb
|
||||||
|
.select()
|
||||||
|
.from(statusHistory)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(statusHistory.entityType, entityType),
|
||||||
|
inArray(statusHistory.entityId, entityIds),
|
||||||
|
lt(statusHistory.timestamp, startSec)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.orderBy(desc(statusHistory.timestamp))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
const priorStatus = lastKnownEvent?.status ?? null;
|
||||||
|
|
||||||
|
const { buckets, totalDowntime } = computeBuckets(
|
||||||
|
events,
|
||||||
|
days,
|
||||||
|
priorStatus
|
||||||
|
);
|
||||||
|
const totalWindow = days * 86400;
|
||||||
|
const overallUptime =
|
||||||
|
totalWindow > 0
|
||||||
|
? Math.max(0, ((totalWindow - totalDowntime) / totalWindow) * 100)
|
||||||
|
: 100;
|
||||||
|
|
||||||
|
const result: BatchedStatusHistoryResponse = {
|
||||||
|
entityType,
|
||||||
|
entityIds,
|
||||||
|
days: buckets,
|
||||||
|
overallUptimePercent: Math.round(overallUptime * 100) / 100,
|
||||||
|
totalDowntimeSeconds: totalDowntime
|
||||||
|
};
|
||||||
|
|
||||||
|
// await cache.set(cacheKey, result, STATUS_HISTORY_CACHE_TTL);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
import {
|
||||||
|
getCachedStatusHistory,
|
||||||
|
statusHistoryQuerySchema,
|
||||||
|
StatusHistoryResponse
|
||||||
|
} from "@server/lib/statusHistory";
|
||||||
|
|
||||||
|
const siteParamsSchema = z.object({
|
||||||
|
siteId: z.string().transform((v) => parseInt(v, 10))
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function getBatchedSiteStatusHistory(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const parsedParams = siteParamsSchema.safeParse(req.params);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const parsedQuery = statusHistoryQuerySchema.safeParse(req.query);
|
||||||
|
if (!parsedQuery.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedQuery.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const entityType = "site";
|
||||||
|
const entityId = parsedParams.data.siteId;
|
||||||
|
const { days } = parsedQuery.data;
|
||||||
|
|
||||||
|
const data = await getCachedStatusHistory(entityType, entityId, days);
|
||||||
|
|
||||||
|
return response<StatusHistoryResponse>(res, {
|
||||||
|
data,
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Status history retrieved successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,11 +16,8 @@ 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, type GetIdpResponse } from "@server/routers/idp";
|
import { ListIdpsResponse } 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"
|
||||||
@@ -32,9 +29,8 @@ 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 user = await verifySession({ skipCheckVerifyEmail: true });
|
const getUser = cache(verifySession);
|
||||||
|
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;
|
||||||
@@ -89,47 +85,19 @@ 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 =
|
const idpsRes = await cache(
|
||||||
await priv.get<AxiosResponse<ListIdpsResponse>>("/idp");
|
async () =>
|
||||||
|
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();
|
||||||
@@ -192,7 +160,6 @@ 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" ||
|
||||||
|
|||||||
+4
-1
@@ -5,6 +5,7 @@ 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";
|
||||||
@@ -12,6 +13,7 @@ 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,7 +29,8 @@ export default async function Page(props: {
|
|||||||
|
|
||||||
const env = pullEnv();
|
const env = pullEnv();
|
||||||
|
|
||||||
const user = await verifySession({ skipCheckVerifyEmail: true });
|
const getUser = cache(verifySession);
|
||||||
|
const user = await getUser({ skipCheckVerifyEmail: true });
|
||||||
|
|
||||||
let complete = false;
|
let complete = false;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,25 +1,26 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { generateOidcUrlProxy } from "@app/actions/server";
|
import { useEffect, useState } from "react";
|
||||||
import IdpTypeIcon from "@app/components/IdpTypeIcon";
|
|
||||||
import { Alert, AlertDescription } from "@app/components/ui/alert";
|
|
||||||
import { Button } from "@app/components/ui/button";
|
import { Button } from "@app/components/ui/button";
|
||||||
import { cleanRedirect } from "@app/lib/cleanRedirect";
|
import { Alert, AlertDescription } from "@app/components/ui/alert";
|
||||||
import { LAST_USED_IDP_COOKIE_NAME } from "@app/lib/consts";
|
|
||||||
import { setClientCookie } from "@app/lib/setClientCookie";
|
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
import IdpTypeIcon from "@app/components/IdpTypeIcon";
|
||||||
|
import {
|
||||||
|
generateOidcUrlProxy,
|
||||||
|
type GenerateOidcUrlResponse
|
||||||
|
} from "@app/actions/server";
|
||||||
import {
|
import {
|
||||||
redirect as redirectTo,
|
redirect as redirectTo,
|
||||||
useRouter,
|
useParams,
|
||||||
useSearchParams
|
useSearchParams
|
||||||
} from "next/navigation";
|
} from "next/navigation";
|
||||||
import { useEffect, useState, useTransition } from "react";
|
import { useRouter } from "next/navigation";
|
||||||
|
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 = {
|
||||||
@@ -34,6 +35,7 @@ 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();
|
||||||
@@ -50,22 +52,10 @@ 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);
|
||||||
@@ -78,6 +68,7 @@ export default function IdpLoginButtons({
|
|||||||
|
|
||||||
if (response.error) {
|
if (response.error) {
|
||||||
setError(response.message);
|
setError(response.message);
|
||||||
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,6 +84,7 @@ export default function IdpLoginButtons({
|
|||||||
"An unexpected error occurred. Please try again."
|
"An unexpected error occurred. Please try again."
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (redirectToUrl) {
|
if (redirectToUrl) {
|
||||||
@@ -132,38 +124,20 @@ export default function IdpLoginButtons({
|
|||||||
idp.variant || idp.name.toLowerCase();
|
idp.variant || idp.name.toLowerCase();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Button
|
||||||
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}
|
||||||
>
|
>
|
||||||
<Button
|
<IdpTypeIcon type={effectiveType} size={16} />
|
||||||
key={idp.idpId}
|
<span>{idp.name}</span>
|
||||||
type="button"
|
</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>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -30,7 +30,10 @@ 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 { generateOidcUrlProxy, loginProxy } from "@app/actions/server";
|
import {
|
||||||
|
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";
|
||||||
@@ -38,13 +41,11 @@ 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 = {
|
||||||
@@ -104,6 +105,7 @@ 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") })
|
||||||
@@ -128,16 +130,11 @@ 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();
|
||||||
const { code } = mfaForm.getValues();
|
const { code } = mfaForm.getValues();
|
||||||
|
|
||||||
setLastUsedIdpId(null);
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
@@ -182,7 +179,8 @@ export default function LoginForm({
|
|||||||
if (data.useSecurityKey) {
|
if (data.useSecurityKey) {
|
||||||
setError(
|
setError(
|
||||||
t("securityKeyRequired", {
|
t("securityKeyRequired", {
|
||||||
defaultValue: "Please use your security key to sign in."
|
defaultValue:
|
||||||
|
"Please use your security key to sign in."
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
@@ -244,8 +242,6 @@ 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,
|
||||||
@@ -360,6 +356,7 @@ export default function LoginForm({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|
||||||
{!mfaRequested && (
|
{!mfaRequested && (
|
||||||
<>
|
<>
|
||||||
<SecurityKeyAuthButton
|
<SecurityKeyAuthButton
|
||||||
@@ -388,41 +385,25 @@ export default function LoginForm({
|
|||||||
idp.variant || idp.name.toLowerCase();
|
idp.variant || idp.name.toLowerCase();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Button
|
||||||
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);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Button
|
<IdpTypeIcon type={effectiveType} size={16} />
|
||||||
key={idp.idpId}
|
<span>{idp.name}</span>
|
||||||
type="button"
|
</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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -22,8 +22,6 @@ import Link from "next/link";
|
|||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import { cleanRedirect } from "@app/lib/cleanRedirect";
|
import { cleanRedirect } from "@app/lib/cleanRedirect";
|
||||||
import MfaInputForm from "@app/components/MfaInputForm";
|
import MfaInputForm from "@app/components/MfaInputForm";
|
||||||
import { LAST_USED_IDP_COOKIE_NAME } from "@app/lib/consts";
|
|
||||||
import { setClientCookie } from "@app/lib/setClientCookie";
|
|
||||||
|
|
||||||
type LoginPasswordFormProps = {
|
type LoginPasswordFormProps = {
|
||||||
identifier: string;
|
identifier: string;
|
||||||
@@ -84,12 +82,6 @@ export default function LoginPasswordForm({
|
|||||||
const { password } = values;
|
const { password } = values;
|
||||||
const { code } = mfaForm.getValues();
|
const { code } = mfaForm.getValues();
|
||||||
|
|
||||||
// delete last used auth cookie by setting it in the past
|
|
||||||
setClientCookie(LAST_USED_IDP_COOKIE_NAME, JSON.stringify(null), {
|
|
||||||
sameSite: "Lax",
|
|
||||||
days: -1
|
|
||||||
});
|
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
|
|||||||
@@ -27,8 +27,6 @@ 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")
|
||||||
@@ -55,7 +53,6 @@ type SmartLoginFormProps = {
|
|||||||
forceLogin?: boolean;
|
forceLogin?: boolean;
|
||||||
defaultUser?: string;
|
defaultUser?: string;
|
||||||
orgSignIn?: OrgSignInConfig;
|
orgSignIn?: OrgSignInConfig;
|
||||||
lastUsedIdp?: (LoginFormIDP & { orgId?: string }) | null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type ViewState =
|
type ViewState =
|
||||||
@@ -92,8 +89,7 @@ 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();
|
||||||
@@ -298,15 +294,6 @@ 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}
|
||||||
|
|||||||
@@ -17,8 +17,6 @@ import {
|
|||||||
} from "next/navigation";
|
} from "next/navigation";
|
||||||
import { cleanRedirect } from "@app/lib/cleanRedirect";
|
import { cleanRedirect } from "@app/lib/cleanRedirect";
|
||||||
import { Separator } from "@app/components/ui/separator";
|
import { Separator } from "@app/components/ui/separator";
|
||||||
import { setClientCookie } from "@app/lib/setClientCookie";
|
|
||||||
import { LAST_USED_IDP_COOKIE_NAME } from "@app/lib/consts";
|
|
||||||
|
|
||||||
type SmartLoginOrgSelectorProps = {
|
type SmartLoginOrgSelectorProps = {
|
||||||
identifier: string;
|
identifier: string;
|
||||||
@@ -143,17 +141,6 @@ export default function SmartLoginOrgSelector({
|
|||||||
setPendingIdpId(idpId);
|
setPendingIdpId(idpId);
|
||||||
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 {
|
||||||
const safeRedirect = cleanRedirect(redirect || "/");
|
const safeRedirect = cleanRedirect(redirect || "/");
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export const LAST_USED_IDP_COOKIE_NAME = "p__last_used_idp";
|
|
||||||
@@ -640,6 +640,28 @@ export const orgQueries = {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
batchedSiteStatusHistory: ({
|
||||||
|
siteIds,
|
||||||
|
days = 90
|
||||||
|
}: {
|
||||||
|
siteIds: number[];
|
||||||
|
days?: number;
|
||||||
|
}) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: [
|
||||||
|
"SITE_STATUS_HISTORY",
|
||||||
|
"BATCHED",
|
||||||
|
siteIds,
|
||||||
|
days
|
||||||
|
] as const,
|
||||||
|
queryFn: async ({ signal, meta }) => {
|
||||||
|
// TODO
|
||||||
|
// const res = await meta!.api.get<
|
||||||
|
// AxiosResponse<StatusHistoryResponse>
|
||||||
|
// >(`/site/${siteId}/status-history?days=${days}`, { signal });
|
||||||
|
// return res.data.data;
|
||||||
|
}
|
||||||
|
}),
|
||||||
siteStatusHistory: ({
|
siteStatusHistory: ({
|
||||||
siteId,
|
siteId,
|
||||||
days = 90
|
days = 90
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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;
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user