diff --git a/store/demo_seed_test.go b/store/demo_seed_test.go new file mode 100644 index 00000000..5572d6df --- /dev/null +++ b/store/demo_seed_test.go @@ -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) +} diff --git a/store/seed/sqlite/01__dump.sql b/store/seed/sqlite/01__dump.sql index 93a1ffc8..03c59183 100644 --- a/store/seed/sqlite/01__dump.sql +++ b/store/seed/sqlite/01__dump.sql @@ -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","🎉","💡","✅"]}', ''); diff --git a/web/src/components/PagedMemoList/PagedMemoList.tsx b/web/src/components/PagedMemoList/PagedMemoList.tsx index d3dc2150..79cb07b7 100644 --- a/web/src/components/PagedMemoList/PagedMemoList.tsx +++ b/web/src/components/PagedMemoList/PagedMemoList.tsx @@ -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 ? ( + + ) : 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 ? (
{memoEditor} + {emptyPlaceholder}
) : undefined; - // Pagination spinner, empty state, and back-to-top are identical across both layouts. + // Pagination controls are identical across both layouts. const footer = ( <> {isFetchingNextPage && } - {!isFetchingNextPage && !hasNextPage && sortedMemoList.length === 0 && !memoEditor && ( - - )} {!isFetchingNextPage && (hasNextPage || sortedMemoList.length > 0) && (
@@ -276,6 +280,7 @@ const PagedMemoList = (props: Props) => { {memoEditor} {sortedMemoList.map((memo) => props.renderer(memo, { compact: effectiveCompact }))} + {emptyPlaceholder} {footer} )} diff --git a/web/src/components/PasswordSignInForm.tsx b/web/src/components/PasswordSignInForm.tsx index d9c01985..9902f84f 100644 --- a/web/src/components/PasswordSignInForm.tsx +++ b/web/src/components/PasswordSignInForm.tsx @@ -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) => { const text = e.target.value as string; diff --git a/web/tests/paged-memo-list.test.tsx b/web/tests/paged-memo-list.test.tsx index 83c9d457..cc94d936 100644 --- a/web/tests/paged-memo-list.test.tsx +++ b/web/tests/paged-memo-list.test.tsx @@ -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 = () =>
) => +const renderList = ( + renderer: (memo: Memo, options: { compact: boolean }) => React.ReactElement = () =>
, + options: { showMemoEditor?: boolean } = {}, +) => render( - + , ); @@ -64,6 +67,28 @@ describe("", () => { 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]; diff --git a/web/tests/password-sign-in-form.test.tsx b/web/tests/password-sign-in-form.test.tsx new file mode 100644 index 00000000..77d1fd19 --- /dev/null +++ b/web/tests/password-sign-in-form.test.tsx @@ -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("", () => { + it("does not prefill seeded demo credentials", () => { + render(); + + expect(screen.getByPlaceholderText("common.username")).toHaveValue(""); + expect(screen.getByPlaceholderText("common.password")).toHaveValue(""); + }); +});