chore(demo): harden SSO-only demo experience
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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("");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user