chore(demo): harden SSO-only demo experience

This commit is contained in:
boojack
2026-07-13 00:31:15 +08:00
parent ad6d009767
commit a9fcd459f6
6 changed files with 129 additions and 17 deletions
+53
View File
@@ -0,0 +1,53 @@
package store_test
import (
"context"
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
"golang.org/x/crypto/bcrypt"
"github.com/usememos/memos/internal/profile"
"github.com/usememos/memos/store"
"github.com/usememos/memos/store/db/sqlite"
)
func TestDemoSeedRequiresIdentityProviderSignIn(t *testing.T) {
ctx := context.Background()
p := &profile.Profile{
Demo: true,
Data: t.TempDir(),
Driver: "sqlite",
DSN: filepath.Join(t.TempDir(), "demo.db"),
}
driver, err := sqlite.NewDB(p)
require.NoError(t, err)
stores := store.New(driver, p)
t.Cleanup(func() {
require.NoError(t, stores.Close())
})
require.NoError(t, stores.Migrate(ctx))
generalSetting, err := stores.GetInstanceGeneralSetting(ctx)
require.NoError(t, err)
require.True(t, generalSetting.DisallowPasswordAuth)
require.False(t, generalSetting.DisallowUserRegistration, "SSO first-login provisioning must remain enabled")
demoUsername := "demo"
demoUser, err := stores.GetUser(ctx, &store.FindUser{Username: &demoUsername})
require.NoError(t, err)
require.NotNil(t, demoUser)
require.Equal(t, store.RoleAdmin, demoUser.Role)
require.Error(t, bcrypt.CompareHashAndPassword([]byte(demoUser.PasswordHash), []byte("demo")))
demoCost, err := bcrypt.Cost([]byte(demoUser.PasswordHash))
require.NoError(t, err)
require.GreaterOrEqual(t, demoCost, 12)
aliceUsername := "alice"
aliceUser, err := stores.GetUser(ctx, &store.FindUser{Username: &aliceUsername})
require.NoError(t, err)
require.NotNil(t, aliceUser)
require.Error(t, bcrypt.CompareHashAndPassword([]byte(aliceUser.PasswordHash), []byte("demo")))
require.NotEqual(t, demoUser.PasswordHash, aliceUser.PasswordHash)
}
+5 -4
View File
@@ -1,8 +1,8 @@
-- Demo User (Admin) — password: demo
INSERT INTO user (id,username,role,nickname,password_hash) VALUES(1,'demo','ADMIN','Demo User','$2a$10$c.slEVgf5b/3BnAWlLb/vOu7VVSOKJ4ljwMe9xzlx9IhKnvAsJYM6');
-- Demo User (Admin) — the random source password was discarded; use SSO or the demo access token.
INSERT INTO user (id,username,role,nickname,password_hash) VALUES(1,'demo','ADMIN','Demo User','$2y$12$A/8h4XS6hQmRVVSRFQHsR.wWVJbSG40avhuphOBdK5ws99W1kunr6');
-- Alice (User) — password: demo
INSERT INTO user (id,username,role,nickname,description,password_hash) VALUES(2,'alice','USER','Alice','Developer & avid reader 📚','$2a$10$c.slEVgf5b/3BnAWlLb/vOu7VVSOKJ4ljwMe9xzlx9IhKnvAsJYM6');
-- Alice (User) — the random source password was discarded; sign in through SSO.
INSERT INTO user (id,username,role,nickname,description,password_hash) VALUES(2,'alice','USER','Alice','Developer & avid reader 📚','$2y$12$CaqLYlvpHjL1qbVNny0lre59ctT2doIvVLh4Pn1yipsDUtbcbeum6');
-- 1. Welcome Memo (Pinned) — newest created_ts so it leads the pinned section
INSERT INTO memo (id,uid,creator_id,created_ts,updated_ts,content,visibility,pinned,payload) VALUES(1,'welcome2memos001',1,strftime('%s','now','-2 days'),strftime('%s','now','-2 days'),replace('# Welcome to Memos 👋\n\nAn open-source, self-hosted note-taking tool for people who think in fragments. Capture quickly, organize lightly, own everything.\n\n> Most apps treat notes like documents. Memos treats them like thoughts — short, timestamped, searchable.\n\n## Try it right now\n\n- [x] Open this memo\n- [ ] React with 🎉 below\n- [ ] Scroll the timeline to see what others have written\n- [ ] Write your own first memo\n\n## What you can do here\n\n| Feature | Example |\n|---------|---------|\n| **Markdown** | Headings, **bold**, *italic*, `code`, ~~strikethrough~~ |\n| **Tags** | Type `#anything` and it becomes a filter |\n| **Task lists** | `- [ ]` checkboxes that toggle inline |\n| **Code blocks** | Fenced blocks with syntax highlighting |\n| **Tables** | Pipes and dashes — yes, this one |\n| **Attachments** | Drag images, videos, or files right in |\n| **Location** | Geotag a memo to where you wrote it |\n| **Relations** | Link memos together as references or replies |\n\n## Self-host in one command\n\n```bash\ndocker run -d -p 5230:5230 -v ~/.memos:/var/opt/memos neosmemo/memos:stable\n```\n\nThen open `http://localhost:5230` and start writing.\n\n---\n\nScroll the timeline to see each feature used in real memos. #welcome #getting-started','\n',char(10)),'PUBLIC',1,'{"tags":["welcome","getting-started"],"property":{"hasLink":false,"hasCode":true,"hasTaskList":true,"hasIncompleteTasks":true}}');
@@ -59,4 +59,5 @@ INSERT INTO reaction (id,creator_id,content_id,reaction_type) VALUES(12,2,'memos
INSERT INTO user_setting (user_id,key,value) VALUES(1,'PERSONAL_ACCESS_TOKENS','{"tokens":[{"tokenId":"demo-access-token","tokenHash":"7631cdaa5b56a39371dab01d5d186fd73f05602cc8ad29bf72ffef3713badd9d","description":"Demo access token","createdAt":"2024-01-01T00:00:00Z"}]}');
-- System Settings
INSERT INTO system_setting VALUES ('GENERAL', '{"disallowPasswordAuth":true}', 'Require identity provider sign-in for the public demo.');
INSERT INTO system_setting VALUES ('MEMO_RELATED', '{"contentLengthLimit":8192,"enableAutoCompact":true,"enableComment":true,"enableLocation":true,"defaultVisibility":"PUBLIC","reactions":["👍","💛","🔥","👏","😂","👌","🚀","👀","🤔","🤡","❓","+1","🎉","💡","✅"]}', '');
@@ -221,25 +221,29 @@ const PagedMemoList = (props: Props) => {
// Stable reference so MentionResolutionProvider's memo (keyed on the array) actually holds.
const contents = useMemo(() => sortedMemoList.map((memo) => memo.content), [sortedMemoList]);
const emptyPlaceholder =
!isFetchingNextPage && !hasNextPage && sortedMemoList.length === 0 ? (
<Placeholder variant="empty" message={t("message.no-data")} className="w-full" />
) : null;
// Column one is the action column: the composer and any active filters head it, and the
// newest memo lands directly beneath them (priorityKey above). Every vertical seam inside
// the stack uses GRID_GAP so y-spacing matches the grid's x-spacing exactly.
// empty state follows them. The newest memo also lands directly beneath them (priorityKey
// above). Every vertical seam inside the stack uses GRID_GAP so y-spacing matches the
// grid's x-spacing exactly.
const hasFilters = filters.length > 0;
const gridLeading =
memoEditor || hasFilters ? (
memoEditor || hasFilters || emptyPlaceholder ? (
<div className="flex w-full flex-col" style={{ gap: GRID_GAP }}>
{memoEditor}
<MemoFilters />
{emptyPlaceholder}
</div>
) : undefined;
// Pagination spinner, empty state, and back-to-top are identical across both layouts.
// Pagination controls are identical across both layouts.
const footer = (
<>
{isFetchingNextPage && <Loader />}
{!isFetchingNextPage && !hasNextPage && sortedMemoList.length === 0 && !memoEditor && (
<Placeholder variant="empty" message={t("message.no-data")} />
)}
{!isFetchingNextPage && (hasNextPage || sortedMemoList.length > 0) && (
<div className="w-full opacity-70 flex flex-row justify-center items-center my-4">
<BackToTop />
@@ -276,6 +280,7 @@ const PagedMemoList = (props: Props) => {
{memoEditor}
<MemoFilters className="mb-2" />
{sortedMemoList.map((memo) => props.renderer(memo, { compact: effectiveCompact }))}
{emptyPlaceholder}
{footer}
</>
)}
+2 -4
View File
@@ -7,7 +7,6 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { authServiceClient } from "@/connect";
import { useAuth } from "@/contexts/AuthContext";
import { useInstance } from "@/contexts/InstanceContext";
import useLoading from "@/hooks/useLoading";
import useNavigateTo from "@/hooks/useNavigateTo";
import { handleError } from "@/lib/error";
@@ -21,11 +20,10 @@ interface PasswordSignInFormProps {
function PasswordSignInForm({ redirectPath }: PasswordSignInFormProps) {
const t = useTranslate();
const navigateTo = useNavigateTo();
const { profile } = useInstance();
const { initialize } = useAuth();
const actionBtnLoadingState = useLoading(false);
const [username, setUsername] = useState(profile.demo ? "demo" : "");
const [password, setPassword] = useState(profile.demo ? "secret" : "");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const handleUsernameInputChanged = (e: React.ChangeEvent<HTMLInputElement>) => {
const text = e.target.value as string;
+27 -2
View File
@@ -43,10 +43,13 @@ vi.mock("@/components/MemoEditor", () => ({
const memo = { name: "memos/1", content: "hello", updateTime: undefined } as unknown as Memo;
const renderList = (renderer: (memo: Memo, options: { compact: boolean }) => React.ReactElement = () => <div />) =>
const renderList = (
renderer: (memo: Memo, options: { compact: boolean }) => React.ReactElement = () => <div />,
options: { showMemoEditor?: boolean } = {},
) =>
render(
<QueryClientProvider client={new QueryClient()}>
<PagedMemoList renderer={renderer} />
<PagedMemoList renderer={renderer} showMemoEditor={options.showMemoEditor} />
</QueryClientProvider>,
);
@@ -64,6 +67,28 @@ describe("<PagedMemoList>", () => {
expect(screen.getByTestId("placeholder-sprite")).toBeInTheDocument();
});
it("shows the empty state below the memo editor", () => {
renderList(undefined, { showMemoEditor: true });
expect(screen.getByTestId("memo-editor")).toBeInTheDocument();
expect(screen.getByText("No data found.")).toBeInTheDocument();
expect(screen.getByTestId("placeholder-sprite")).toBeInTheDocument();
});
it("places the empty state in the first grid column", () => {
view.maxColumns = 0;
const widthSpy = vi.spyOn(Element.prototype, "clientWidth", "get").mockReturnValue(1200);
try {
renderList(undefined, { showMemoEditor: true });
const leadingTile = screen.getByText("No data found.").closest(".absolute");
expect(leadingTile).not.toBeNull();
expect(leadingTile).toContainElement(screen.getByTestId("memo-editor"));
} finally {
widthSpy.mockRestore();
}
});
describe("compact policy", () => {
beforeEach(() => {
feed.memos = [memo];
+30
View File
@@ -0,0 +1,30 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import PasswordSignInForm from "@/components/PasswordSignInForm";
vi.mock("@/auth-state", () => ({ setAccessToken: vi.fn() }));
vi.mock("@/connect", () => ({
authServiceClient: { signIn: vi.fn() },
}));
vi.mock("@/contexts/AuthContext", () => ({
useAuth: () => ({ initialize: vi.fn() }),
}));
vi.mock("@/hooks/useNavigateTo", () => ({
default: () => vi.fn(),
}));
vi.mock("@/utils/i18n", () => ({
useTranslate: () => (key: string) => key,
}));
describe("<PasswordSignInForm>", () => {
it("does not prefill seeded demo credentials", () => {
render(<PasswordSignInForm />);
expect(screen.getByPlaceholderText("common.username")).toHaveValue("");
expect(screen.getByPlaceholderText("common.password")).toHaveValue("");
});
});