Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions messages/cs-CZ.json
Original file line number Diff line number Diff line change
Expand Up @@ -586,6 +586,9 @@
"8HimVK": "Potvrďte heslo",
"PdMcML": "Resetuji...",
"Yy_yDL": "Resetovat heslo",
"nfREwD": "Přihlášení vypršelo",
"BXx4Ts": "Chcete-li pokračovat, přihlaste se znovu. Neuložené úpravy na této stránce se mohou ztratit.",
"scfgdM": "Přihlásit znovu",
"w3UX-L": "Účet aplikace {name} s touto e-mailovou adresou již existuje, ale poskytovatel identity adresu neověřil. Požádejte správce o její ověření.",
"GZuWet": "S touto identitou se nelze přihlásit do aplikace {name}. Obraťte se na správce.",
"d3nOZp": "Tato instance aplikace {name} nepřijímá nové účty. Obraťte se na správce.",
Expand Down
3 changes: 3 additions & 0 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -586,6 +586,9 @@
"8HimVK": "Confirm password",
"PdMcML": "Resetting...",
"Yy_yDL": "Reset password",
"nfREwD": "Your sign-in has expired",
"BXx4Ts": "Sign in again to continue. Unsaved edits on this page may be lost.",
"scfgdM": "Sign in again",
"w3UX-L": "A {name} account with this email already exists, but your identity provider has not verified the address. Ask your administrator to verify it.",
"GZuWet": "This identity cannot sign in to {name}. Ask your administrator.",
"d3nOZp": "This {name} instance does not accept new accounts. Ask your administrator.",
Expand Down
12 changes: 11 additions & 1 deletion src/app/(app)/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { AppChromeProvider } from "@/components/app-chrome-provider";
import { SessionWatcher } from "@/components/auth/session-watcher";
import { Footer } from "@/components/footer";
import { HelpProvider } from "@/components/help/help-provider";
import { RebrandBanner } from "@/components/rebrand-banner";
import { getSession } from "@/lib/auth-server";
import { env } from "@/lib/env";
import { appChromeConfig } from "@/lib/settings/app-chrome";

Expand All @@ -10,10 +12,18 @@ export default async function AppLayout({
}: {
children: React.ReactNode;
}) {
const config = await appChromeConfig();
const [config, session] = await Promise.all([
appChromeConfig(),
getSession(),
]);
return (
<HelpProvider>
<AppChromeProvider {...config}>
{session ? (
<SessionWatcher
expiresAt={session.session.expiresAt.toISOString()}
/>
) : null}
<div className="flex flex-col min-h-[100vh]">
{/* In-app rebrand banner. Hosted-only: existing OpenPlaud
users land here directly (auth redirect from `/`) and
Expand Down
19 changes: 15 additions & 4 deletions src/app/(auth)/login/page.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,23 @@
import { redirect } from "next/navigation";
import { getExtracted, getLocale } from "next-intl/server";
import {
HostedAuthChrome,
SelfHostAuthChrome,
} from "@/components/auth/auth-chrome";
import { LoginForm } from "@/components/auth/login-form";
import { SsoLogin } from "@/components/auth/sso-login";
import { redirectIfAuthenticated } from "@/lib/auth-server";
import { loginReturnPath } from "@/lib/auth/return-path";
import { getSession } from "@/lib/auth-server";
import { deploymentProductName } from "@/lib/deployment-brand";
import { env } from "@/lib/env";
import { isSmtpConfigured } from "@/lib/smtp";
import { isSsoEnabled } from "@/lib/sso/config";

interface LoginPageProps {
searchParams: Promise<{ error?: string | string[] }>;
searchParams: Promise<{
error?: string | string[];
returnTo?: string | string[];
}>;
}

export default async function LoginPage({ searchParams }: LoginPageProps) {
Expand All @@ -22,10 +27,14 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
const instanceSubtitle = i18n("Sign in to your {name} instance.", {
name,
});
await redirectIfAuthenticated();
const { error, returnTo } = await searchParams;
const destination = loginReturnPath(
Array.isArray(returnTo) ? returnTo[0] : returnTo,
);
if ((await getSession())?.user) redirect(destination);
const clientReturnTo = returnTo === undefined ? undefined : destination;

if (isSsoEnabled()) {
const { error } = await searchParams;
return (
<SelfHostAuthChrome
title={i18n("Sign in")}
Expand All @@ -34,12 +43,14 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
<SsoLogin
providerName={env.OIDC_PROVIDER_NAME}
error={Array.isArray(error) ? error[0] : error}
returnTo={clientReturnTo}
/>
</SelfHostAuthChrome>
);
}

const formProps = {
returnTo: clientReturnTo,
registrationEnabled: !env.DISABLE_REGISTRATION,
smtpConfigured: isSmtpConfigured(),
};
Expand Down
5 changes: 4 additions & 1 deletion src/components/auth/login-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,11 @@ import { toast } from "sonner";
import { MetalButton } from "@/components/metal-button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { loginReturnPath } from "@/lib/auth/return-path";
import { signIn } from "@/lib/auth-client";

interface LoginFormProps {
returnTo?: string;
/**
* Whether the "Don't have an account? Register" footer link should be
* shown. Default `true`. Set to `false` when `DISABLE_REGISTRATION=true`
Expand All @@ -38,6 +40,7 @@ interface LoginFormProps {
export function LoginForm({
registrationEnabled = true,
smtpConfigured = false,
returnTo,
}: LoginFormProps) {
const i18n = useExtracted();
const [email, setEmail] = useState("");
Expand Down Expand Up @@ -69,7 +72,7 @@ export function LoginForm({
}

toast.success(i18n("Logged in successfully"));
push("/dashboard");
push(loginReturnPath(returnTo));
refresh();
} catch (error) {
const message =
Expand Down
139 changes: 139 additions & 0 deletions src/components/auth/session-watcher.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
"use client";

import { useExtracted } from "next-intl";
import { useEffect, useState } from "react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { clearSsoReturnPath, loginReturnPath } from "@/lib/auth/return-path";
import { observeSessionExpiry } from "@/lib/auth/session-expiry";
import { authClient } from "@/lib/auth-client";

const MAX_TIMER_DELAY = 2_147_483_647;
const CHECK_THROTTLE = 60_000;
const SKEW_RETRY_DELAYS = [30_000, 60_000, 120_000];

/** Watch a signed-in page for confirmed session expiry and offer a return to sign-in. */
export function SessionWatcher({ expiresAt }: { expiresAt: string }) {
const i18n = useExtracted();
const [open, setOpen] = useState(false);
const [loginUrl, setLoginUrl] = useState("/login");

useEffect(() => {
let active = true;
let checking = false;
let lastCheck = -Infinity;
let timer: ReturnType<typeof setTimeout> | undefined;
let expirySignal = 0;
let skewRetries = 0;

clearSsoReturnPath();
setOpen(false);
const showExpiry = () => {
expirySignal++;
const returnPath = loginReturnPath(
`${window.location.pathname}${window.location.search}${window.location.hash}`,
);
setLoginUrl(`/login?returnTo=${encodeURIComponent(returnPath)}`);
setOpen(true);
};

const schedule = (deadline: number) => {
clearTimeout(timer);
if (!Number.isFinite(deadline)) return;
const delay = Math.max(0, deadline - Date.now());
timer = setTimeout(
() => {
if (deadline > Date.now()) schedule(deadline);
else void check();
},
Math.min(delay, MAX_TIMER_DELAY),
);
};

const check = async () => {
if (checking) return;
checking = true;
lastCheck = Date.now();
const signal = expirySignal;
try {
const result = await authClient.getSession({
query: { disableCookieCache: true },
});
if (!active || result.error || signal !== expirySignal) return;
if (result.data === null) {
clearTimeout(timer);
showExpiry();
} else if (result.data?.session) {
setOpen(false);
const deadline = new Date(
result.data.session.expiresAt,
).getTime();
clearTimeout(timer);
if (deadline > Date.now()) {
skewRetries = 0;
schedule(deadline);
} else if (Number.isFinite(deadline)) {
const delay = SKEW_RETRY_DELAYS[skewRetries++];
if (delay !== undefined) schedule(Date.now() + delay);
}
}
} catch {
} finally {
checking = false;
}
};

const recheck = () => {
if (Date.now() - lastCheck >= CHECK_THROTTLE) void check();
};
const onVisible = () => {
if (document.visibilityState === "visible") recheck();
};

const stopObserving = observeSessionExpiry(showExpiry);
schedule(Date.parse(expiresAt));
document.addEventListener("visibilitychange", onVisible);
window.addEventListener("online", recheck);
return () => {
active = false;
clearTimeout(timer);
stopObserving();
document.removeEventListener("visibilitychange", onVisible);
window.removeEventListener("online", recheck);
};
}, [expiresAt]);

return (
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogContent
className="z-[1000000000]"
overlayClassName="z-[1000000000]"
>
<AlertDialogHeader>
<AlertDialogTitle>
{i18n("Your sign-in has expired")}
</AlertDialogTitle>
<AlertDialogDescription>
{i18n(
"Sign in again to continue. Unsaved edits on this page may be lost.",
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{i18n("Later")}</AlertDialogCancel>
<AlertDialogAction asChild>
<a href={loginUrl}>{i18n("Sign in again")}</a>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
21 changes: 18 additions & 3 deletions src/components/auth/sso-login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ import { useState } from "react";
import { toast } from "sonner";
import { MetalButton } from "@/components/metal-button";
import { useProductName } from "@/components/product-name-provider";
import {
loginReturnPath,
recalledSsoReturnPath,
rememberSsoReturnPath,
} from "@/lib/auth/return-path";
import { signIn } from "@/lib/auth-client";
import {
SSO_ACCESS_DENIED_ERROR,
Expand All @@ -17,6 +22,7 @@ import {
} from "@/lib/sso/constants";

interface SsoLoginProps {
returnTo?: string;
/** The identity provider's name, as the operator configured it. */
providerName: string;
/** The `error` code the sign-in callback redirected back with, if any. */
Expand All @@ -27,7 +33,7 @@ interface SsoLoginProps {
* Sign-in through the instance's OpenID Connect provider: the only way in
* when single sign-on is configured.
*/
export function SsoLogin({ providerName, error }: SsoLoginProps) {
export function SsoLogin({ providerName, error, returnTo }: SsoLoginProps) {
const i18n = useExtracted();
const name = useProductName();
const [isLoading, setIsLoading] = useState(false);
Expand Down Expand Up @@ -82,11 +88,20 @@ export function SsoLogin({ providerName, error }: SsoLoginProps) {

const handleClick = async () => {
setIsLoading(true);
const destination = loginReturnPath(
returnTo ?? (error ? recalledSsoReturnPath() : undefined),
);
const callbackURL = new URL(destination, window.location.origin).href;
const errorCallbackURL = new URL("/login", window.location.origin);
if (destination !== "/dashboard") {
errorCallbackURL.searchParams.set("returnTo", destination);
}
rememberSsoReturnPath(destination);
try {
const result = await signIn.oauth2({
providerId: SSO_PROVIDER_ID,
callbackURL: "/dashboard",
errorCallbackURL: "/login",
callbackURL,
errorCallbackURL: errorCallbackURL.href,
});
if (result.error) {
toast.error(i18n("Sign-in failed. Please try again."));
Expand Down
4 changes: 3 additions & 1 deletion src/components/ui/alert-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,14 +51,16 @@ function AlertDialogOverlay({

function AlertDialogContent({
className,
overlayClassName,
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content> & {
size?: "default" | "sm";
overlayClassName?: string;
}) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogOverlay className={overlayClassName} />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
data-size={size}
Expand Down
3 changes: 3 additions & 0 deletions src/lib/api-errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ export async function toastApiError(
opts: ToastApiErrorOptions,
): Promise<ApiErrorBody> {
const body = await parseApiError(response);
if (response.status === 401 && body.code === "AUTH_SESSION_MISSING") {
return body;
}
const message = body.error || opts.fallback || "Request failed";
const errorId =
typeof body.details?.errorId === "string"
Expand Down
Loading
Loading