From 51dfdcec6be80ebf9538a6e2ebf9ec9b5ca3442f Mon Sep 17 00:00:00 2001
From: Maximilian Kaske
Date: Thu, 1 Oct 2026 21:35:55 +0200
Subject: [PATCH] fix: login page
---
.../src/app/login/_components/actions.ts | 97 ++++++++-----------
.../{magic-link-form.tsx => email-form.tsx} | 17 ++--
.../app/login/_components/login-button.tsx | 4 +-
.../src/app/login/_components/sso-form.tsx | 51 ----------
apps/dashboard/src/app/login/page.tsx | 56 +++++------
.../src/content/pages/changelog/saml-sso.mdx | 2 +
.../docs/guides/how-to-set-up-saml-sso.mdx | 2 +-
7 files changed, 83 insertions(+), 146 deletions(-)
rename apps/dashboard/src/app/login/_components/{magic-link-form.tsx => email-form.tsx} (74%)
delete mode 100644 apps/dashboard/src/app/login/_components/sso-form.tsx
diff --git a/apps/dashboard/src/app/login/_components/actions.ts b/apps/dashboard/src/app/login/_components/actions.ts
index 0971e43a..8e9bd86e 100644
--- a/apps/dashboard/src/app/login/_components/actions.ts
+++ b/apps/dashboard/src/app/login/_components/actions.ts
@@ -9,6 +9,10 @@ import { signIn } from "@/lib/auth";
import { ssoLookupRateLimit } from "@/lib/rate-limit/sso-lookup";
import { SSO_ORG_COOKIE } from "@/lib/sso-cookie";
+const hasWorkOS = Boolean(
+ process.env.AUTH_WORKOS_ID && process.env.AUTH_WORKOS_SECRET,
+);
+
// Same-origin paths only; the Auth.js `redirect` callback is the second line
// of defense, not the first.
function sanitizeRedirectTo(raw: FormDataEntryValue | null) {
@@ -16,80 +20,63 @@ function sanitizeRedirectTo(raw: FormDataEntryValue | null) {
return value.startsWith("/") && !value.startsWith("//") ? value : undefined;
}
-export type MagicLinkFormState = { sent?: boolean; error?: string };
+export type EmailFormState = { sent?: boolean; error?: string };
// One message for every refusal (bad address, disposable domain, throttled,
// send failure): the form must not tell a caller which addresses exist or
// what we filter. The screening itself runs in the Resend provider.
-const MAGIC_LINK_ERROR =
+const EMAIL_ERROR =
"We couldn't send a sign-in link to that address. Try GitHub or Google.";
-export async function signInWithMagicLink(
- _prevState: MagicLinkFormState,
+/**
+ * Routes by domain: a verified SSO domain goes to the identity provider,
+ * everything else gets a magic link.
+ */
+export async function continueWithEmail(
+ _prevState: EmailFormState,
formData: FormData,
-): Promise {
+): Promise {
const email = String(formData.get("email") ?? "").trim();
- if (!email.includes("@")) return { error: MAGIC_LINK_ERROR };
+ if (!email.includes("@")) return { error: EMAIL_ERROR };
+
+ const redirectTo = sanitizeRedirectTo(formData.get("redirectTo"));
+
+ if (hasWorkOS) {
+ const ip = resolveClientIp(await headers()) ?? "unknown";
+ if (!(await ssoLookupRateLimit(ip))) return { error: EMAIL_ERROR };
+
+ const workspace = await getWorkspaceByVerifiedSsoDomain(email);
+ if (workspace?.workosOrganizationId) {
+ const cookieStore = await cookies();
+ cookieStore.set(SSO_ORG_COOKIE, workspace.workosOrganizationId, {
+ httpOnly: true,
+ secure: process.env.NODE_ENV === "production",
+ sameSite: "lax",
+ maxAge: 60 * 10,
+ path: "/",
+ });
+ await signIn(
+ "workos",
+ { redirectTo: redirectTo ?? "/overview" },
+ { organization: workspace.workosOrganizationId },
+ );
+ return {};
+ }
+ }
// next-auth lifts `redirectTo` into the magic link's `callbackUrl` itself.
// In a server action Auth.js rethrows `AuthError`s; anything else comes back
// as the `?error=` URL it would have redirected to.
try {
- const url = await signIn("resend", {
- email,
- redirectTo: sanitizeRedirectTo(formData.get("redirectTo")),
- redirect: false,
- });
+ const url = await signIn("resend", { email, redirectTo, redirect: false });
if (typeof url === "string" && new URL(url).searchParams.has("error")) {
- return { error: MAGIC_LINK_ERROR };
+ return { error: EMAIL_ERROR };
}
} catch (e) {
if (!(e instanceof AuthError)) throw e;
console.error("magic link sign-in failed", e);
- return { error: MAGIC_LINK_ERROR };
+ return { error: EMAIL_ERROR };
}
return { sent: true };
}
-
-export type SsoFormState = { error?: string };
-
-// Deliberately identical for "no such domain", "SSO disabled" and "rate
-// limited": a specific message would tell an unauthenticated caller which
-// companies use openstatus and which of them have SSO.
-const GENERIC_ERROR =
- "We couldn't start SSO for that email. Try GitHub or Google.";
-
-export async function startSsoSignIn(
- _prevState: SsoFormState,
- formData: FormData,
-): Promise {
- const email = String(formData.get("email") ?? "");
- const redirectTo =
- sanitizeRedirectTo(formData.get("redirectTo")) ?? "/overview";
-
- if (!email.includes("@")) return { error: GENERIC_ERROR };
-
- const ip = resolveClientIp(await headers()) ?? "unknown";
- if (!(await ssoLookupRateLimit(ip))) return { error: GENERIC_ERROR };
-
- const workspace = await getWorkspaceByVerifiedSsoDomain(email);
- if (!workspace?.workosOrganizationId) return { error: GENERIC_ERROR };
-
- const cookieStore = await cookies();
- cookieStore.set(SSO_ORG_COOKIE, workspace.workosOrganizationId, {
- httpOnly: true,
- secure: process.env.NODE_ENV === "production",
- sameSite: "lax",
- maxAge: 60 * 10,
- path: "/",
- });
-
- await signIn(
- "workos",
- { redirectTo },
- { organization: workspace.workosOrganizationId },
- );
-
- return {};
-}
diff --git a/apps/dashboard/src/app/login/_components/magic-link-form.tsx b/apps/dashboard/src/app/login/_components/email-form.tsx
similarity index 74%
rename from apps/dashboard/src/app/login/_components/magic-link-form.tsx
rename to apps/dashboard/src/app/login/_components/email-form.tsx
index e9402136..c233b3aa 100644
--- a/apps/dashboard/src/app/login/_components/magic-link-form.tsx
+++ b/apps/dashboard/src/app/login/_components/email-form.tsx
@@ -3,14 +3,14 @@
import { Input } from "@openstatus/ui/components/ui/input";
import { useActionState } from "react";
-import { type MagicLinkFormState, signInWithMagicLink } from "./actions";
+import { type EmailFormState, continueWithEmail } from "./actions";
import { LoginButton } from "./login-button";
-const initialState: MagicLinkFormState = {};
+const initialState: EmailFormState = {};
-export function MagicLinkForm({ redirectTo }: { redirectTo?: string }) {
+export function EmailForm({ redirectTo }: { redirectTo?: string }) {
const [state, formAction, isPending] = useActionState(
- signInWithMagicLink,
+ continueWithEmail,
initialState,
);
@@ -42,8 +42,13 @@ export function MagicLinkForm({ redirectTo }: { redirectTo?: string }) {
{state.error ? (
{state.error}
) : null}
-
- {isPending ? "Sending…" : "Continue with email"}
+
+ {isPending ? "Continuing…" : "Continue"}
);
diff --git a/apps/dashboard/src/app/login/_components/login-button.tsx b/apps/dashboard/src/app/login/_components/login-button.tsx
index 01f55753..327cf1dc 100644
--- a/apps/dashboard/src/app/login/_components/login-button.tsx
+++ b/apps/dashboard/src/app/login/_components/login-button.tsx
@@ -42,8 +42,8 @@ export function LoginButton({
{children}
{isLastUsed ? (
Last used
diff --git a/apps/dashboard/src/app/login/_components/sso-form.tsx b/apps/dashboard/src/app/login/_components/sso-form.tsx
deleted file mode 100644
index 92976c96..00000000
--- a/apps/dashboard/src/app/login/_components/sso-form.tsx
+++ /dev/null
@@ -1,51 +0,0 @@
-"use client";
-
-import { Button } from "@openstatus/ui/components/ui/button";
-import { Input } from "@openstatus/ui/components/ui/input";
-import { useActionState, useState } from "react";
-
-import { type SsoFormState, startSsoSignIn } from "./actions";
-import { LoginButton } from "./login-button";
-
-const initialState: SsoFormState = {};
-
-export function SsoForm({ redirectTo }: { redirectTo?: string }) {
- const [expanded, setExpanded] = useState(false);
- const [state, formAction, isPending] = useActionState(
- startSsoSignIn,
- initialState,
- );
-
- if (!expanded) {
- return (
- setExpanded(true)}
- >
- Sign in with SSO
-
- );
- }
-
- return (
-
- );
-}
diff --git a/apps/dashboard/src/app/login/page.tsx b/apps/dashboard/src/app/login/page.tsx
index b6ecf7b8..3c747004 100644
--- a/apps/dashboard/src/app/login/page.tsx
+++ b/apps/dashboard/src/app/login/page.tsx
@@ -7,15 +7,10 @@ import type { SearchParams } from "nuqs/server";
import { signIn } from "@/lib/auth";
+import { EmailForm } from "./_components/email-form";
import { LoginButton } from "./_components/login-button";
-import { MagicLinkForm } from "./_components/magic-link-form";
-import { SsoForm } from "./_components/sso-form";
import { searchParamsCache } from "./search-params";
-const hasWorkOS = Boolean(
- process.env.AUTH_WORKOS_ID && process.env.AUTH_WORKOS_SECRET,
-);
-
// Auth.js error codes that land on `/login?error=`; anything else stays silent.
const ERROR_MESSAGES: Record = {
AccessDenied:
@@ -57,30 +52,30 @@ export default async function Page(props: {
) : null}
-
+
-
-
+
+
+
+
{process.env.AUTH_OIDC_ISSUER ? (
) : null}
- {hasWorkOS ?
: null}
By clicking continue, you agree to our{" "}
diff --git a/apps/web/src/content/pages/changelog/saml-sso.mdx b/apps/web/src/content/pages/changelog/saml-sso.mdx
index 0e87437a..4a978518 100644
--- a/apps/web/src/content/pages/changelog/saml-sso.mdx
+++ b/apps/web/src/content/pages/changelog/saml-sso.mdx
@@ -13,6 +13,8 @@ Head to **Settings > SSO**, enable it, and verify your domain with a DNS TXT rec
On the login page your team selects **Sign in with SSO** and enters their work email — they're redirected to your identity provider and added to the workspace as members on first login. Signing in from your provider's app tile works too. Anyone who already has an openstatus account with the same email keeps it, along with their role.
+_Edit: there is no separate SSO button anymore. Enter your work email and click **Continue** — a verified SSO domain is sent to your identity provider, any other address receives a magic link._
+
GitHub and Google stay available — SSO is an additional way in, not a replacement.
SSO is a paid add-on, available on the Starter, Team, and Scale plans. Self-hosting? Point the dashboard at any OIDC provider with `AUTH_OIDC_ISSUER`, `AUTH_OIDC_ID`, and `AUTH_OIDC_SECRET` instead.
diff --git a/apps/web/src/content/pages/docs/guides/how-to-set-up-saml-sso.mdx b/apps/web/src/content/pages/docs/guides/how-to-set-up-saml-sso.mdx
index e3ac0822..86babaa3 100644
--- a/apps/web/src/content/pages/docs/guides/how-to-set-up-saml-sso.mdx
+++ b/apps/web/src/content/pages/docs/guides/how-to-set-up-saml-sso.mdx
@@ -41,7 +41,7 @@ When the connection goes live, the SSO page shows **Ready — your team can sign
## Signing in
-On the login page your team selects **Sign in with SSO** and enters their work email. The domain is matched against your verified domains and they're redirected to your identity provider.
+On the login page your team enters their work email and clicks **Continue**. The domain is matched against your verified domains and they're redirected to your identity provider instead of receiving a magic link.
Users signing in this way are added to your workspace automatically as **members** on first login. Someone who already has an openstatus account with the same email keeps that account, along with any role they already hold — an existing owner stays an owner.
--
2.51.2