-
-

-
{instanceGeneralSetting.customProfile?.title || "Memos"}
-
- {!instanceGeneralSetting.disallowPasswordAuth ? (
-
- ) : (
- identityProviderList.length === 0 &&
Password auth is not allowed.
- )}
- {!instanceGeneralSetting.disallowUserRegistration && !instanceGeneralSetting.disallowPasswordAuth && (
-
- {t("auth.sign-up-tip")}
-
- {t("common.sign-up")}
-
-
- )}
- {identityProviderList.length > 0 && (
- <>
- {!instanceGeneralSetting.disallowPasswordAuth && (
-
+
+ {passwordAuthAllowed || hasIdentityProviders ? (
+ <>
+ {hasIdentityProviders && }
+ {hasIdentityProviders && passwordAuthAllowed && (
+
+
+
+
+ {t("common.or")}
+
- )}
-
- {identityProviderList.map((identityProvider) => (
-
- ))}
- >
- )}
-
-
-
+ )}
+ {passwordAuthAllowed &&
}
+ {passwordAuthAllowed && !instanceGeneralSetting.disallowUserRegistration && (
+
+ )}
+ >
+ ) : (
+
}
+ title={t("auth.signin-unavailable-title")}
+ description={t("auth.signin-unavailable-description")}
+ >
+
+ {t("auth.admin-sign-in")}
+
+
+
+ )}
+
);
};
diff --git a/web/src/pages/SignUp.tsx b/web/src/pages/SignUp.tsx
index 014694fa..e767406e 100644
--- a/web/src/pages/SignUp.tsx
+++ b/web/src/pages/SignUp.tsx
@@ -1,22 +1,24 @@
import { create } from "@bufbuild/protobuf";
import { timestampDate } from "@bufbuild/protobuf/wkt";
-import { LoaderIcon } from "lucide-react";
+import { InfoIcon, LoaderIcon, LockIcon, SparklesIcon, UserRoundXIcon } from "lucide-react";
import { useState } from "react";
import { toast } from "react-hot-toast";
-import { Link, useSearchParams } from "react-router-dom";
+import { useSearchParams } from "react-router-dom";
import { setAccessToken } from "@/auth-state";
-import AuthFooter from "@/components/AuthFooter";
+import AuthPageLayout, { AuthChip, AuthEmptyState, AuthLinkPrompt } from "@/components/AuthPageLayout";
+import CredentialFields from "@/components/CredentialFields";
+import IdentityProviderButtons from "@/components/IdentityProviderButtons";
import { Button } from "@/components/ui/button";
-import { Input } from "@/components/ui/input";
import { authServiceClient, userServiceClient } from "@/connect";
import { useAuth } from "@/contexts/AuthContext";
import { useInstance } from "@/contexts/InstanceContext";
+import { useIdentityProviderList } from "@/hooks/useIdentityProviderQueries";
import useLoading from "@/hooks/useLoading";
import useNavigateTo from "@/hooks/useNavigateTo";
import { handleError } from "@/lib/error";
import { ROUTES } from "@/router/routes";
import { User_Role, UserSchema } from "@/types/proto/api/v1/user_service_pb";
-import { AUTH_REDIRECT_PARAM, getSafeRedirectPath } from "@/utils/auth-redirect";
+import { AUTH_REDIRECT_PARAM, appendSearchParams, getSafeRedirectPath } from "@/utils/auth-redirect";
import { useTranslate } from "@/utils/i18n";
const SignUp = () => {
@@ -29,25 +31,18 @@ const SignUp = () => {
const { generalSetting: instanceGeneralSetting, profile, initialize: initInstance } = useInstance();
const [searchParams] = useSearchParams();
const redirectTarget = getSafeRedirectPath(searchParams.get(AUTH_REDIRECT_PARAM));
- const signInPath = searchParams.toString() ? `${ROUTES.AUTH}?${searchParams.toString()}` : ROUTES.AUTH;
- const canUsePasswordSignUp = !instanceGeneralSetting.disallowUserRegistration && !instanceGeneralSetting.disallowPasswordAuth;
+ const signInPath = appendSearchParams(ROUTES.AUTH, searchParams);
- const handleUsernameInputChanged = (e: React.ChangeEvent
) => {
- const text = e.target.value as string;
- setUsername(text);
- };
+ const passwordAuthAllowed = !instanceGeneralSetting.disallowPasswordAuth;
+ const registrationOpen = !instanceGeneralSetting.disallowUserRegistration;
+ const needsSetup = profile.needsSetup;
+ // Provider buttons only render on the SSO-provisioned branch below; skip the request elsewhere.
+ const identityProviderList = useIdentityProviderList(!needsSetup && registrationOpen && !passwordAuthAllowed);
+ const hasIdentityProviders = identityProviderList.length > 0;
- const handlePasswordInputChanged = (e: React.ChangeEvent) => {
- const text = e.target.value as string;
- setPassword(text);
- };
-
- const handleFormSubmit = (e: React.FormEvent) => {
+ const handleFormSubmit = async (e: React.FormEvent) => {
e.preventDefault();
- handleSignUpButtonClick();
- };
- const handleSignUpButtonClick = async () => {
if (username === "" || password === "") {
return;
}
@@ -87,75 +82,87 @@ const SignUp = () => {
actionBtnLoadingState.setFinish();
};
- return (
-
-
-
-

-
{instanceGeneralSetting.customProfile?.title || "Memos"}
+ const signUpForm = (
+
+ );
+
+ const signInPrompt =
;
+
+ // First run: create the instance owner account.
+ if (needsSetup) {
+ return (
+
+
+ {t("auth.first-run")}
+
+ }
+ title={t("auth.setup-title")}
+ subtitle={t("auth.setup-description")}
+ hideExplore
+ >
+ {signUpForm}
+
+
+ {t("auth.setup-note")}
- {canUsePasswordSignUp ? (
- <>
- {t("auth.create-your-account")}
-
- >
- ) : instanceGeneralSetting.disallowPasswordAuth ? (
- Password sign up is not allowed.
+
+ );
+ }
+
+ // Registration closed.
+ if (!registrationOpen) {
+ return (
+
+ }
+ title={t("auth.signups-closed-title")}
+ description={t("auth.signups-closed-description")}
+ />
+ {signInPrompt}
+
+ );
+ }
+
+ // Password sign-up disallowed: accounts come from the identity provider.
+ if (!passwordAuthAllowed) {
+ return (
+
+ {hasIdentityProviders ? (
+
) : (
- Sign up is not allowed.
+ }
+ title={t("auth.signup-unavailable-title")}
+ description={t("auth.signup-unavailable-description")}
+ />
)}
- {profile.needsSetup ? (
- {t("auth.host-tip")}
- ) : (
-
- {t("auth.sign-in-tip")}
-
- {t("common.sign-in")}
-
-
- )}
-
-
-
+ {signInPrompt}
+
+ );
+ }
+
+ // Open registration.
+ return (
+
+ {signUpForm}
+ {signInPrompt}
+
);
};
diff --git a/web/src/router/routes.ts b/web/src/router/routes.ts
index 3f5734f5..916c5118 100644
--- a/web/src/router/routes.ts
+++ b/web/src/router/routes.ts
@@ -8,6 +8,9 @@ export const ROUTES = {
SETTING: "/setting",
EXPLORE: "/explore",
AUTH: "/auth",
+ AUTH_SIGNUP: "/auth/signup",
+ AUTH_ADMIN: "/auth/admin",
+ AUTH_CALLBACK: "/auth/callback",
SHARED_MEMO: "/memos/shares",
} as const;
diff --git a/web/src/utils/auth-redirect.ts b/web/src/utils/auth-redirect.ts
index 9d195342..4c70e37a 100644
--- a/web/src/utils/auth-redirect.ts
+++ b/web/src/utils/auth-redirect.ts
@@ -9,6 +9,7 @@ export {
AUTH_REASON_PARAM,
AUTH_REASON_PROTECTED_MEMO,
AUTH_REDIRECT_PARAM,
+ appendSearchParams,
buildAuthRoute,
getSafeRedirectPath,
isPublicRoute,
diff --git a/web/src/utils/redirect-safety.ts b/web/src/utils/redirect-safety.ts
index 3a88c417..234dc5b5 100644
--- a/web/src/utils/redirect-safety.ts
+++ b/web/src/utils/redirect-safety.ts
@@ -87,3 +87,12 @@ export function shouldGatePrivateInstance(params: { isPrivateInstance: boolean;
}
return !pathname.startsWith(`${ROUTES.SHARED_MEMO}/`);
}
+
+/**
+ * Appends the given search params to a path, so links between auth pages
+ * (sign-in <-> sign-up) preserve the redirect target and other params.
+ */
+export function appendSearchParams(path: string, searchParams: URLSearchParams): string {
+ const queryString = searchParams.toString();
+ return queryString ? `${path}?${queryString}` : path;
+}
diff --git a/web/tests/auth-page-layout.test.tsx b/web/tests/auth-page-layout.test.tsx
new file mode 100644
index 00000000..3754d6c1
--- /dev/null
+++ b/web/tests/auth-page-layout.test.tsx
@@ -0,0 +1,52 @@
+import { render, screen } from "@testing-library/react";
+import { MemoryRouter } from "react-router-dom";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import AuthPageLayout from "@/components/AuthPageLayout";
+
+const instance = vi.hoisted(() => ({ instanceUrl: "" }));
+
+vi.mock("@/contexts/InstanceContext", () => ({
+ useInstance: () => ({ profile: instance, generalSetting: {} }),
+}));
+
+vi.mock("@/utils/i18n", () => ({
+ useTranslate: () => (key: string) => key,
+ loadLocale: vi.fn(),
+}));
+
+vi.mock("@/components/AuthFooter", () => ({ default: () =>
}));
+
+const renderLayout = (props?: { hideExplore?: boolean }) =>
+ render(
+
+
+
+
+ ,
+ );
+
+describe("
explore band", () => {
+ beforeEach(() => {
+ instance.instanceUrl = "";
+ });
+
+ it("links to Explore on public instances", () => {
+ instance.instanceUrl = "https://demo.example.com";
+ renderLayout();
+
+ expect(screen.getByRole("link", { name: /auth\.explore-public-memos/ })).toHaveAttribute("href", "/explore");
+ });
+
+ it("omits the band on private instances", () => {
+ renderLayout();
+
+ expect(screen.queryByRole("link", { name: /auth\.explore-public-memos/ })).not.toBeInTheDocument();
+ });
+
+ it("omits the band when hideExplore is set (first-run setup)", () => {
+ instance.instanceUrl = "https://demo.example.com";
+ renderLayout({ hideExplore: true });
+
+ expect(screen.queryByRole("link", { name: /auth\.explore-public-memos/ })).not.toBeInTheDocument();
+ });
+});