diff --git a/TODOS.md b/TODOS.md new file mode 100644 index 0000000..2f2727b --- /dev/null +++ b/TODOS.md @@ -0,0 +1,7 @@ +# TODOS + +## Add philosophy/workflow guidance to MCP tool descriptions +**What:** Enhance MCP tool descriptions in `routes/mcp.py` to include behavioral guidance (when to create pages, visibility recommendations, titling conventions) alongside the current functional descriptions. +**Why:** Claude Code (the primary agent path) reads MCP tool descriptions, not `/api/v1/agent-setup`. This is where philosophy guidance has the highest impact on actual agent behavior. Codex review flagged this as higher leverage than the agent-setup endpoint changes. +**Context:** Currently MCP tool descriptions are functional only (e.g., "Create a new page. Content should be markdown starting with `# Title`."). Philosophy guidance lives in `agent-setup` (for the copy-paste prompt path) but not in MCP (for the native Claude Code path). The `list_pages` MCP tool already returns conventions with AGENTS content, but the tool descriptions themselves don't prime the agent's behavior. +**Depends on:** Nothing. Can be done independently of the agent-setup philosophy changes. diff --git a/app.py b/app.py index 9ee12f4..34074d5 100644 --- a/app.py +++ b/app.py @@ -83,7 +83,7 @@ def make_session_permanent(): _CSP = ( "default-src 'self'; script-src 'self' https://static.cloudflareinsights.com;" " connect-src 'self' https://cloudflareinsights.com; style-src 'self';" - " img-src 'self' https://*.fly.storage.tigris.dev" + " img-src 'self' blob: https://*.fly.storage.tigris.dev" ) diff --git a/cli/jottit_cli/commands/publish.py b/cli/jottit_cli/commands/publish.py index 9d0507c..7d388cb 100644 --- a/cli/jottit_cli/commands/publish.py +++ b/cli/jottit_cli/commands/publish.py @@ -25,9 +25,9 @@ def _read_content(file, title): @click.option("--slug", help="Custom slug for the page") @click.option( "--visibility", - type=click.Choice(["unlisted", "listed", "pinned"]), + type=click.Choice(["private", "unlisted", "listed", "pinned"]), default=None, - help="Page visibility (default: unlisted)", + help="Page visibility (default: private)", ) @click.option("--title", help="Page title (prepended as # heading if missing)") @click.option( diff --git a/db.py b/db.py index 11edbf3..6b45346 100644 --- a/db.py +++ b/db.py @@ -1,5 +1,6 @@ import hashlib import importlib.util +import json import os import secrets import threading @@ -577,6 +578,53 @@ def create_api_token(user_id, name): return token, row["id"] +def get_or_create_api_token(user_id, name): + """Return an existing token by name, or create one. Returns (token_str, token_id). + token_str is None if the token already existed (value not stored).""" + with get_db() as conn: + existing = conn.execute( + "SELECT id FROM api_tokens WHERE user_id = %s AND name = %s LIMIT 1", + (user_id, name), + ).fetchone() + if existing: + return None, existing["id"] + return create_api_token(user_id, name) + + +def get_setup_checklist(user_id): + """Return checklist status for the getting-started flow.""" + with get_db() as conn: + page_count = conn.execute( + "SELECT COUNT(*) AS c FROM pages WHERE user_id = %s", + (user_id,), + ).fetchone()["c"] + token_count = conn.execute( + "SELECT COUNT(*) AS c FROM api_tokens WHERE user_id = %s", + (user_id,), + ).fetchone()["c"] + mcp_used = conn.execute( + "SELECT EXISTS(SELECT 1 FROM revisions r JOIN pages p ON r.page_id = p.id WHERE p.user_id = %s AND r.source = 'mcp') AS e", + (user_id,), + ).fetchone()["e"] + return { + "has_pages": page_count > 0, + "has_token": token_count > 0, + "has_mcp": bool(mcp_used), + } + + +def log_event(user_id, event, metadata=None): + try: + with get_db() as conn: + conn.execute( + "INSERT INTO user_events (user_id, event, metadata) VALUES (%s, %s, %s)", + (user_id, event, json.dumps(metadata) if metadata else None), + ) + conn.commit() + except Exception: + pass + + def get_api_tokens(user_id): with get_db() as conn: return conn.execute( diff --git a/migrations/026_add_private_visibility.sql b/migrations/026_add_private_visibility.sql new file mode 100644 index 0000000..0a3d9dc --- /dev/null +++ b/migrations/026_add_private_visibility.sql @@ -0,0 +1,4 @@ +-- Re-add 'private' as a valid visibility state. +-- Private pages are only visible to their owner. +-- Lifecycle: private → unlisted → listed → pinned +-- (Previously removed in migration 018.) diff --git a/migrations/027_add_user_events.sql b/migrations/027_add_user_events.sql new file mode 100644 index 0000000..f42be93 --- /dev/null +++ b/migrations/027_add_user_events.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS user_events ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + event TEXT NOT NULL, + metadata JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS user_events_user_id_idx ON user_events (user_id); +CREATE INDEX IF NOT EXISTS user_events_event_idx ON user_events (event, created_at DESC); diff --git a/routes/__init__.py b/routes/__init__.py index c697ae1..25cbfcd 100644 --- a/routes/__init__.py +++ b/routes/__init__.py @@ -43,7 +43,7 @@ }, } -VISIBILITY_OPTIONS = ("unlisted", "listed", "pinned") +VISIBILITY_OPTIONS = ("private", "unlisted", "listed", "pinned") def _get_subdomain(): @@ -106,6 +106,15 @@ def can_edit(page_meta): return is_creator(page_meta) +def check_page_visibility(page_meta): + """Abort 404 if page is private and current user is not the owner.""" + if not page_meta: + return + if page_meta.get("visibility") == "private": + if session.get("user_id") != page_meta["user_id"]: + abort(404) + + def send_verification(email, purpose): code = create_verification_code(email, purpose) send_verification_email(email, code) diff --git a/routes/admin.py b/routes/admin.py index e13763e..03033ac 100644 --- a/routes/admin.py +++ b/routes/admin.py @@ -1,6 +1,6 @@ import re -from flask import flash, redirect, render_template, request, session +from flask import flash, jsonify, redirect, render_template, request, session from db import ( check_username_available, @@ -8,6 +8,9 @@ delete_api_token, delete_user, find_or_create_user, + get_or_create_api_token, + log_event, + set_user_username, get_api_tokens, get_pages_for_user, get_user, @@ -50,9 +53,6 @@ def signin(): "signin.html", error="Please enter a valid email address." ) - if not user_exists(email): - return render_template("signin.html", not_found=True) - send_verification(email, "signin") return redirect("/signin/verify") @@ -83,8 +83,10 @@ def signin_verify(): session["user_id"] = user_id user = get_user(user_id) if user and user.get("username"): + log_event(user_id, "signin") return redirect(profile_url(user["username"])) - return redirect("/") + log_event(user_id, "signup") + return redirect("/setup/username") @bp.route("/signout", methods=["POST"]) @@ -93,6 +95,108 @@ def signout(): return redirect("/") +@bp.route("/setup/username", methods=["GET", "POST"]) +def setup_username(): + user_id = session.get("user_id") + if not user_id: + return redirect("/signin") + user = get_user(user_id) + if user and user.get("username"): + return redirect(profile_url(user["username"])) + + if request.method == "GET": + return render_template("setup_username.html") + + username = request.form.get("username", "").strip().lower() + if not username: + return render_template("setup_username.html", error="Username is required.") + if not valid_username(username): + return render_template( + "setup_username.html", + error="Username must be lowercase letters, numbers, and hyphens only.", + username=username, + ) + if username in RESERVED_USERNAMES: + return render_template( + "setup_username.html", + error="That username is reserved.", + username=username, + ) + if not check_username_available(username): + return render_template( + "setup_username.html", + error="That username is already taken.", + username=username, + ) + + set_user_username(user_id, username) + update_user_settings(user_id, name="", username=username, bio="", license=None) + return redirect("/setup/profile") + + +@bp.route("/setup/profile", methods=["GET", "POST"]) +def setup_profile(): + user_id = session.get("user_id") + if not user_id: + return redirect("/signin") + user = get_user(user_id) + if not user or not user.get("username"): + return redirect("/setup/username") + + if request.method == "GET": + return render_template("setup_profile.html", username=user["username"]) + + name = request.form.get("name", "").strip() + if name: + update_user_settings( + user_id, name=name, username=user["username"], bio="", license=None + ) + + file = request.files.get("avatar") + if file and file.filename: + error = validate_image(file) + if not error: + ext = ALLOWED_IMAGE_TYPES[file.content_type] + fmt = file.content_type.split("/")[-1].upper() + if fmt == "JPG": + fmt = "JPEG" + cropped = crop_square(file, fmt) + key = f"{user_id}/avatar.{ext}" + url = upload_image(key, cropped, file.content_type) + update_user_avatar(user_id, url) + + return redirect(profile_url(user["username"])) + + +@bp.route("/setup/mcp-config", methods=["POST"]) +def setup_mcp_config(): + user_id = session.get("user_id") + if not user_id: + return jsonify({"error": "Unauthorized"}), 401 + user = get_user(user_id) + if not user: + return jsonify({"error": "Unauthorized"}), 401 + + token, token_id = get_or_create_api_token(user_id, "mcp-default") + if token is None: + # Token already exists but we can't show the stored value. + # Rotate: delete old, create new. + delete_api_token(token_id, user_id) + token, _ = create_api_token(user_id, "mcp-default") + + base_url = request.url_root.rstrip("/") + prompt = ( + f"You are connected to my Jottit, a place where ideas become " + f"beautiful pages on the web. When I share an insight worth keeping, " + f"capture it as a page. " + f"Fetch {base_url}/api/v1/agent-setup with the header " + f"'Authorization: Bearer {token}' for API details and my conventions. " + f"Then list my pages to understand what's already there." + ) + log_event(user_id, "token_generated") + return jsonify({"config_text": prompt}) + + @bp.route("/pages") def user_pages(): from routes.public import _build_page_item, sidebar_vars, compute_initials @@ -362,6 +466,15 @@ def settings_delete(): return redirect("/") +@bp.route("/settings/mcp") +def settings_mcp(): + user_id, user = require_user() + if not user: + return redirect("/signin") + base_url = request.url_root.rstrip("/") + return render_template("settings_mcp.html", user=user, base_url=base_url) + + @bp.route("/settings/tokens", methods=["GET", "POST"]) @limiter.limit("5 per 5 minutes", methods=["POST"]) def settings_tokens(): diff --git a/routes/api.py b/routes/api.py index d5b12d7..9683133 100644 --- a/routes/api.py +++ b/routes/api.py @@ -59,6 +59,89 @@ def _serialize_page(meta, page_data): } +@api_bp.route("/agent-setup") +def agent_setup(): + user = _require_auth() + if not user: + return _error( + "Unauthorized — include your API token as: Authorization: Bearer YOUR_TOKEN", + 401, + ) + base_url = request.url_root.rstrip("/") + username = user.get("username", "") + pages = get_pages_for_user(user["id"]) + return jsonify( + { + "welcome": f"You are connected to Jottit as @{username}.", + "philosophy": ( + "Jottit turns markdown into beautiful, shareable web pages. " + "When someone shares an insight worth keeping, your job is to " + "capture it as a well-structured page. Think of yourself as a " + "writing partner, not an API client." + ), + "workflow": { + "when_to_create": ( + "When the user shares an insight, idea, or piece of thinking " + "that deserves to exist as its own page. Don't create pages " + "for trivial exchanges." + ), + "when_to_update": ( + "When the user refines or adds to an existing topic. Check " + "list_pages first to avoid duplicates." + ), + "default_visibility": "private", + "titling": ( + "Every page starts with a markdown H1 (# Title). Use clear, " + "descriptive titles. The title becomes the page's identity." + ), + "content_style": ( + "Write in clean markdown. The output will be rendered with " + "beautiful typography. No need for HTML." + ), + }, + "conventions": build_conventions(pages), + "about": "Jottit is a markdown publishing tool. Pages are written in markdown and get a beautiful URL.", + "api_base": f"{base_url}/api/v1", + "your_profile": f"{base_url}/@{username}", + "endpoints": { + "list_pages": { + "method": "GET", + "path": "/pages", + "description": "List all your pages. Returns slug, title, visibility, and updated_at.", + }, + "create_page": { + "method": "POST", + "path": "/pages", + "description": "Create a new page.", + "body": { + "content": "# Title\\n\\nMarkdown content here.", + "slug": "(optional)", + "visibility": "(optional: private, unlisted, listed, pinned)", + }, + }, + "get_page": { + "method": "GET", + "path": "/pages/{slug}", + "description": "Get a page by slug. Returns title, content (markdown), visibility, and updated_at.", + }, + "update_page": { + "method": "PUT", + "path": "/pages/{slug}", + "description": "Update a page. Send content and/or visibility.", + "body": { + "content": "(optional) full markdown", + "visibility": "(optional)", + }, + }, + }, + "auth": "Include your token in every request: Authorization: Bearer YOUR_TOKEN", + "content_format": "All content is markdown. Start with '# Title' on the first line.", + "default_visibility": "New pages are private by default. Change to 'listed' to show on your profile.", + "try_it": f"To verify this works, call GET {base_url}/api/v1/pages to list your pages.", + } + ) + + @api_bp.route("/user") def get_current_user(): user = _require_auth() @@ -155,7 +238,7 @@ def create_page(): slug = slugify(data.get("slug", "")) or generate_slug() if user: - visibility = data.get("visibility", "unlisted") + visibility = data.get("visibility", "private") if visibility not in VISIBILITY_OPTIONS: return _error( f"Visibility must be one of: {', '.join(VISIBILITY_OPTIONS)}", 400 diff --git a/routes/mcp.py b/routes/mcp.py index 93f38e2..ba72947 100644 --- a/routes/mcp.py +++ b/routes/mcp.py @@ -59,7 +59,7 @@ }, "visibility": { "type": "string", - "enum": ["unlisted", "listed", "pinned"], + "enum": ["private", "unlisted", "listed", "pinned"], "default": "unlisted", }, }, @@ -76,7 +76,7 @@ "content": {"type": "string", "description": "Markdown content"}, "visibility": { "type": "string", - "enum": ["unlisted", "listed", "pinned"], + "enum": ["private", "unlisted", "listed", "pinned"], }, }, "required": ["slug"], diff --git a/routes/public.py b/routes/public.py index 25daee2..6017c7f 100644 --- a/routes/public.py +++ b/routes/public.py @@ -1,6 +1,5 @@ import json import os -from collections import Counter from email.utils import format_datetime from xml.sax.saxutils import escape as xml_escape @@ -26,6 +25,7 @@ get_page_meta, get_pages_for_user, get_public_pages, + get_setup_checklist, get_revision, get_revision_count, get_revisions_paginated, @@ -51,6 +51,7 @@ LICENSES, _set_profile_user, account_link_vars, + check_page_visibility, compute_initials, find_page, is_creator, @@ -71,40 +72,9 @@ def install_cli(): return send_from_directory("static", "install-cli.sh", mimetype="text/plain") -_VISIBILITY_TABS = ("all", "unlisted", "listed", "pinned") - - @bp.route("/") def home(): - if "user_id" not in session: - return render_template("home.html", **account_link_vars()) - - pages = get_pages_for_user(session["user_id"]) - all_items = [_build_page_item(p) for p in pages if not is_special_slug(p["slug"])] - - counts = Counter(i["visibility"] for i in all_items) - counts["all"] = len(all_items) - - tab = request.args.get("tab", "all") - if tab not in _VISIBILITY_TABS: - tab = "all" - - if tab == "all": - page_list = all_items - else: - page_list = [i for i in all_items if i["visibility"] == tab] - - user = g.current_user - display_name = (user.get("name") or user.get("username")) if user else None - - return render_template( - "home.html", - pages=page_list, - tab=tab, - counts=counts, - display_name=display_name, - **account_link_vars(), - ) + return render_template("home.html", **account_link_vars()) def _build_page_item(p): @@ -121,6 +91,8 @@ def _build_page_item(p): def sidebar_vars(user_id, username, active_slug=None, is_owner=False): + from db import get_api_tokens + all_pages = get_pages_for_user(user_id) pages = [p for p in all_pages if not is_special_slug(p["slug"])] special = [ @@ -142,6 +114,7 @@ def sidebar_vars(user_id, username, active_slug=None, is_owner=False): recent.append({"slug": p["slug"], "title": title or "Untitled"}) if len(recent) >= 5: break + has_token = bool(get_api_tokens(user_id)) if is_owner else False return { "sidebar_pinned": pinned, "sidebar_recent": recent, @@ -149,17 +122,30 @@ def sidebar_vars(user_id, username, active_slug=None, is_owner=False): "sidebar_total": len(pages), "sidebar_active_slug": active_slug, "sidebar_username": username, + "sidebar_has_token": has_token, } def subdomain_home(user): pages = get_pages_for_user(user["id"]) + is_owner = session.get("user_id") == user["id"] + + # INDEX.md: if user has a page with slug "index", render it on the profile + index_html = None + for p in pages: + if p["slug"] == "index": + # Owner sees their index regardless of visibility; + # visitors only see it if listed or unlisted + if is_owner or p["visibility"] in ("listed", "unlisted", "pinned"): + index_html = render_markdown(p["content"]) + break + pinned = [] listed = [] for p in pages: - if is_special_slug(p["slug"]): + if is_special_slug(p["slug"]) or p["slug"] == "index": continue - if p["visibility"] not in ("listed", "pinned"): + if not is_owner and p["visibility"] not in ("listed", "pinned"): continue item = _build_page_item(p) if p["visibility"] == "pinned": @@ -169,7 +155,6 @@ def subdomain_home(user): page_list = pinned + listed site_title = user.get("name") or user.get("username") - is_owner = session.get("user_id") == user["id"] owner_initials = None owner_avatar_url = None profile_incomplete = False @@ -178,12 +163,19 @@ def subdomain_home(user): owner_avatar_url = user.get("avatar") if not user.get("avatar") and not user.get("bio"): profile_incomplete = True + checklist = ( + get_setup_checklist(user["id"]) + if is_owner and not pages and not index_html + else None + ) bio = user.get("bio") bio_html = render_bio(bio, g.url_prefix) if bio else "" return render_template( "profile.html", user=user, pages=page_list, + index_html=index_html, + checklist=checklist, site_title=site_title, site_username=user.get("username", ""), is_owner=is_owner, @@ -356,6 +348,8 @@ def _resolve_page(slug, suffix=""): if not row: abort(404) + check_page_visibility(page_meta) + return page_meta, row @@ -468,7 +462,9 @@ def view_page( revision_count = get_revision_count(page_meta["id"]) - comments_enabled = row.get("comments_enabled", True) + comments_enabled = ( + row.get("comments_enabled", True) and page_meta.get("visibility") != "private" + ) comments = [] reply_to = None if comments_enabled: @@ -546,6 +542,7 @@ def page_history(slug): page_meta = find_page(slug) if not page_meta: abort(404) + check_page_visibility(page_meta) total = get_revision_count(page_meta["id"]) if total == 0: @@ -590,6 +587,7 @@ def view_revision(slug, revision): page_meta = find_page(slug) if not page_meta: abort(404) + check_page_visibility(page_meta) row = get_revision(page_meta["id"], revision) if not row: diff --git a/routes/site.py b/routes/site.py index e538014..2462189 100644 --- a/routes/site.py +++ b/routes/site.py @@ -25,6 +25,7 @@ delete_comment, delete_page, find_or_create_user, + log_event, find_page_owner_for_redirect, get_comment, get_export_pages, @@ -166,8 +167,10 @@ def new_page(): if is_special_slug(slug): visibility = "unlisted" + elif owner_id or session.get("user_id"): + visibility = "private" else: - visibility = "listed" if owner_id else "unlisted" + visibility = "listed" slug = save_page(slug, content, visibility, subdomain_user_id) new_page_meta = get_page_meta(slug, subdomain_user_id) @@ -254,14 +257,20 @@ def edit_page(slug): is_new = page_meta is None subdomain_user_id = subdomain_user["id"] if subdomain_user else None - # Preserve existing visibility on edit; default to "listed" for new pages - # Special slugs are always unlisted + # Preserve existing visibility on edit; default to "private" for signed-in + # users, "listed" for anonymous. Special slugs are always unlisted. if is_special_slug(slug): visibility = "unlisted" + elif page_meta: + visibility = page_meta["visibility"] + elif session.get("user_id") or subdomain_user: + visibility = "private" else: - visibility = page_meta["visibility"] if page_meta else "listed" + visibility = "listed" slug = save_page(slug, content, visibility, subdomain_user_id) + if is_new and session.get("user_id"): + log_event(session["user_id"], "page_create", {"slug": slug}) if is_new: new_page_meta = get_page_meta(slug, subdomain_user_id) if new_page_meta and session.get("user_id") and not subdomain_user_id: diff --git a/schema.sql b/schema.sql index 650e7af..d4bd08b 100644 --- a/schema.sql +++ b/schema.sql @@ -94,6 +94,14 @@ CREATE TABLE IF NOT EXISTS slug_redirects ( created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP ); +CREATE TABLE IF NOT EXISTS user_events ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + event TEXT NOT NULL, + metadata JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + CREATE TABLE IF NOT EXISTS schema_migrations ( filename TEXT PRIMARY KEY, applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW() @@ -119,6 +127,8 @@ CREATE UNIQUE INDEX IF NOT EXISTS slug_redirects_lookup_unclaimed ON slug_redire CREATE INDEX IF NOT EXISTS comments_page_id_idx ON comments (page_id); CREATE INDEX IF NOT EXISTS comments_ip_created_idx ON comments (ip_hash, created_at); CREATE INDEX IF NOT EXISTS comments_user_id_idx ON comments (user_id); +CREATE INDEX IF NOT EXISTS user_events_user_id_idx ON user_events (user_id); +CREATE INDEX IF NOT EXISTS user_events_event_idx ON user_events (event, created_at DESC); -- On fresh DBs (no sites table), seed historical migrations as already applied DO $$ BEGIN @@ -147,7 +157,9 @@ DO $$ BEGIN ('021_add_comments.sql'), ('022_add_comment_user_id.sql'), ('024_drop_comment_author_fields.sql'), - ('025_drop_comment_is_pinned.sql') + ('025_drop_comment_is_pinned.sql'), + ('026_add_private_visibility.sql'), + ('027_add_user_events.sql') ON CONFLICT DO NOTHING; END IF; END $$; diff --git a/static/setup-mcp.js b/static/setup-mcp.js new file mode 100644 index 0000000..1616121 --- /dev/null +++ b/static/setup-mcp.js @@ -0,0 +1,24 @@ +document.addEventListener('DOMContentLoaded', function() { + var btn = document.getElementById('mcp-config-btn'); + var output = document.getElementById('mcp-config-output'); + var text = document.getElementById('mcp-config-text'); + var copyBtn = document.getElementById('mcp-copy-btn'); + if (!btn) return; + var csrf = btn.getAttribute('data-csrf'); + btn.addEventListener('click', function() { + btn.textContent = 'Loading...'; + fetch('/setup/mcp-config', {method: 'POST', headers: {'X-CSRFToken': csrf}}) + .then(function(r) { return r.json(); }) + .then(function(data) { + text.textContent = data.config_text; + output.classList.remove('setup-hidden'); + btn.style.display = 'none'; + }); + }); + copyBtn.addEventListener('click', function() { + navigator.clipboard.writeText(text.textContent).then(function() { + copyBtn.textContent = 'Copied!'; + setTimeout(function() { copyBtn.textContent = 'Copy prompt'; }, 2000); + }); + }); +}); diff --git a/static/setup-profile.js b/static/setup-profile.js new file mode 100644 index 0000000..80870fe --- /dev/null +++ b/static/setup-profile.js @@ -0,0 +1,19 @@ +document.addEventListener('DOMContentLoaded', function() { + var input = document.getElementById('avatar-file'); + var preview = document.getElementById('avatar-preview'); + if (!input || !preview) return; + + preview.addEventListener('click', function() { + input.click(); + }); + + input.addEventListener('change', function() { + if (!input.files || !input.files[0]) return; + var url = URL.createObjectURL(input.files[0]); + var img = document.createElement('img'); + img.src = url; + img.className = 'setup-avatar-img'; + preview.textContent = ''; + preview.appendChild(img); + }); +}); diff --git a/static/theme.css b/static/theme.css index 733dece..4c25a0d 100644 --- a/static/theme.css +++ b/static/theme.css @@ -363,6 +363,12 @@ color: var(--site-color-muted); } +.page-listing-meta { + display: flex; + align-items: center; + gap: var(--space-1); +} + .page-listing-date { font-size: var(--font-size-xs); font-family: var(--site-font-heading); diff --git a/static/ui.css b/static/ui.css index 978e22d..91e7c5a 100644 --- a/static/ui.css +++ b/static/ui.css @@ -139,16 +139,28 @@ flex-direction: column; } +.home-hero-wrapper { + min-height: auto; + padding-top: 200px; + padding-bottom: var(--space-12); +} + .home-main { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; - gap: var(--space-1); - max-width: var(--content-max-width); + gap: var(--space-2); + max-width: 680px; margin: 0 auto; padding: 0 var(--space-5); + padding-bottom: 10vh; + text-align: center; +} + +.home-hero-wrapper .home-main { + padding-bottom: 0; } .logo { @@ -171,11 +183,531 @@ margin-bottom: var(--space-3); } +.home-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-5) var(--space-5); + position: absolute; + top: 0; + left: 0; + right: 0; +} + +@media (min-width: 640px) { + .home-header { + padding: var(--space-5) var(--space-10); + } +} + +.home-header-logo { + font-size: 1.5rem; + font-weight: 800; + text-decoration: none; + letter-spacing: -0.02em; +} + +.home-header .signin-link { + position: static; +} + +.home-header-avatar-link { + display: flex; + align-items: center; +} + +.home-header-avatar { + width: 35px; + height: 35px; + border-radius: 50%; + object-fit: cover; +} + +.home-header-initials { + width: 35px; + height: 35px; + border-radius: 50%; + background: var(--subtle); + color: var(--text-secondary); + display: flex; + align-items: center; + justify-content: center; + font-size: var(--font-size-xs); + font-weight: 600; +} + +.home-headline { + font-size: clamp(2.2rem, 5vw, 3.2rem); + font-weight: 800; + letter-spacing: -0.03em; + line-height: 1.15; + color: var(--text-primary); + margin: 0 0 var(--space-2) 0; +} + +.home-subtitle { + font-size: var(--font-size-lg); + color: var(--text-secondary); + line-height: 1.6; + max-width: 480px; + margin: 0 auto var(--space-5) auto; +} + +.home-signup-form { + display: flex; + gap: 0; + align-items: stretch; + justify-content: center; + border: 1px solid var(--border); + border-radius: var(--radius-pill); + overflow: hidden; + background: var(--bg-primary); + max-width: 440px; + width: 100%; +} + +.home-email-input { + flex: 1; + padding: 14px 16px; + font-size: var(--font-size-base); + border: none; + background: transparent; + color: var(--text-primary); + outline: none; + min-width: 0; +} + +.home-email-input::placeholder { + color: var(--muted); +} + +.home-signup-form .btn { + border-radius: 0; + white-space: nowrap; + padding: 14px 20px; + font-weight: 600; + border: none; + border-left: 1px solid var(--border); + background: var(--brand-1); + color: #fff; +} + +.home-signup-form .btn:hover { + opacity: 0.9; +} + +.home-create-link { + font-size: var(--font-size-sm); + color: var(--muted); + margin-top: var(--space-3); +} + +.home-create-link:hover { + color: var(--text-secondary); +} + .home-hint { font-size: var(--font-size-xs); color: var(--muted); } +/* Landing page below-fold sections */ + +.landing-section { + max-width: 800px; + margin: 0 auto; + padding: 80px var(--space-5); +} + +.landing-label { + display: block; + font-size: var(--font-size-xs); + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--brand-1); + font-weight: 600; + margin-bottom: var(--space-3); +} + +.landing-heading { + font-size: 1.625rem; + font-weight: 700; + letter-spacing: -0.015em; + line-height: 1.25; + color: var(--text); + margin-bottom: var(--space-8); +} + +.landing-divider { + border: none; + border-top: 1px solid var(--border); + max-width: 800px; + margin: 0 auto; +} + +/* Section 1: How it works */ + +.landing-steps { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: var(--space-10); +} + +.landing-step { + text-align: center; +} + +.landing-step-number { + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border-radius: 50%; + background: var(--bg-raised); + color: var(--brand-1); + font-weight: 700; + font-size: var(--font-size-sm); + margin-bottom: var(--space-3); +} + +.landing-step-title { + font-size: var(--font-size-md); + font-weight: 600; + color: var(--text); + margin-bottom: var(--space-2); +} + +.landing-step-body { + font-size: var(--font-size-sm); + color: var(--text-secondary); + line-height: 1.5; +} + +@media (max-width: 640px) { + .landing-steps { + grid-template-columns: 1fr; + gap: var(--space-6); + } +} + +/* Section 2: Agent angle */ + +.landing-agent-card { + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: var(--space-8); +} + +.landing-agent-intro { + font-size: var(--font-size-base); + color: var(--text-secondary); + margin-bottom: var(--space-5); +} + +.landing-code { + background: var(--code-bg); + padding: var(--space-5); + border-radius: var(--radius-sm); + font-family: var(--font-mono); + font-size: var(--font-size-sm); + line-height: 1.7; + overflow-x: auto; +} + +.landing-code code { + background: none; +} + +.landing-code-comment { + color: var(--muted); +} + +.landing-code-string { + color: var(--brand-1); +} + +/* Bottom CTA */ + +.landing-bottom-cta { + text-align: center; + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-2); +} + +.landing-cta-heading { + font-size: var(--font-size-xl); + font-weight: 700; + color: var(--text); + margin-bottom: var(--space-5); +} + +.home-greeting { + font-size: var(--font-size-xl); + font-weight: 700; + margin-bottom: var(--space-3); +} + +.home-tabs { + display: flex; + gap: var(--space-2); + margin-bottom: var(--space-4); + flex-wrap: wrap; +} + +.home-tab { + font-size: var(--font-size-sm); + color: var(--text-secondary); + padding: var(--space-1) var(--space-2); + border-radius: var(--radius); + text-decoration: none; +} + +.home-tab:hover { + background: var(--bg-secondary); +} + +.home-tab-active { + color: var(--text-primary); + background: var(--bg-secondary); + font-weight: 600; +} + +.home-pages { + width: 100%; + max-width: 600px; +} + +.home-empty { + color: var(--text-secondary); +} + +.vis-badge { + font-size: var(--font-size-xs); + padding: 1px 6px; + border-radius: 3px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.vis-private { + background: var(--bg-secondary); + color: var(--text-secondary); +} + +.vis-unlisted { + background: color-mix(in srgb, var(--brand-1) 10%, transparent); + color: var(--brand-1); +} + +.vis-pinned { + background: color-mix(in srgb, var(--brand-2) 10%, transparent); + color: var(--brand-2); +} + +.setup-quickstart { + display: flex; + flex-direction: column; + gap: 0; +} + +.setup-section { + padding: var(--space-4) 0; + border-bottom: 1px solid var(--border); +} + +.setup-section:last-child { + border-bottom: none; +} + +.setup-section-highlight { + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: var(--space-4); + margin-bottom: var(--space-2); +} + +.setup-welcome { + font-size: var(--font-size-xl); + font-weight: 700; + margin: 0 0 var(--space-2) 0; +} + +.setup-section-title { + font-size: var(--font-size-base); + font-weight: 600; + margin: 0 0 var(--space-1) 0; +} + +.setup-section-desc { + font-size: var(--font-size-sm); + color: var(--text-secondary); + margin: 0; +} + +.setup-section-desc a { + font-weight: 600; +} + +.setup-hidden { + display: none !important; +} + +.setup-code { + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: var(--space-3); + margin-top: var(--space-2); + position: relative; + overflow-x: auto; +} + +.setup-code pre { + margin: 0; + font-size: var(--font-size-sm); + line-height: 1.6; + white-space: pre-wrap; + word-break: break-word; +} + +.setup-code-copy { + position: absolute; + top: var(--space-2); + right: var(--space-2); + background: var(--bg-primary); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 2px 8px; + cursor: pointer; + font-size: var(--font-size-sm); + color: var(--text-secondary); + line-height: 1; +} + +.setup-code-copy:hover { + color: var(--text-primary); +} + +.setup-profile-card { + display: flex; + align-items: center; + gap: var(--space-4); + padding: var(--space-5); + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--bg-raised); + width: 100%; + max-width: 420px; + margin-bottom: var(--space-4); +} + +.setup-avatar-upload { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-2); + flex-shrink: 0; +} + +.setup-avatar-preview { + width: 72px; + height: 72px; + border-radius: 50%; + background: var(--bg-surface); + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + cursor: pointer; +} + +.setup-avatar-initials { + font-size: var(--font-size-xl); + font-weight: 700; + color: var(--muted); +} + +.setup-avatar-img { + width: 72px; + height: 72px; + object-fit: cover; + display: block; + border-radius: 50%; +} + +.setup-avatar-label { + font-size: var(--font-size-xs); + color: var(--brand-1); + cursor: pointer; + font-weight: 600; +} + +.setup-avatar-label:hover { + text-decoration: underline; +} + +.setup-profile-fields { + flex: 1; + min-width: 0; +} + +.setup-name-input { + width: 100%; + padding: var(--space-2); + font-size: var(--font-size-lg); + font-weight: 600; + border: none; + border-bottom: 1px solid var(--border); + background: transparent; + color: var(--text-primary); + outline: none; +} + +.setup-name-input::placeholder { + color: var(--muted); + font-weight: 400; +} + +.setup-name-input:focus { + border-color: var(--brand-1); +} + +.setup-username-display { + font-size: var(--font-size-sm); + color: var(--muted); + margin-top: var(--space-1); + padding-left: var(--space-2); + text-align: left; +} + +.setup-profile-actions { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-2); +} + +.setup-skip-link { + font-size: var(--font-size-sm); + color: var(--muted); +} + +.setup-skip-link:hover { + color: var(--text-secondary); +} + +.btn-sm { + font-size: var(--font-size-sm); + padding: 6px 14px; + margin-top: var(--space-2); +} + .user-header { display: flex; align-items: center; @@ -673,6 +1205,17 @@ margin: 0; } +.action-link { + font-size: var(--font-size-sm); + font-weight: 600; + color: var(--brand-1); +} + +.action-link:hover { + text-decoration: underline; +} + + .profile-header--avatar .profile-bio, .profile-header--full .profile-bio { margin: var(--space-1) 0 0; @@ -832,6 +1375,7 @@ } .site-header { + background: var(--bg); border-bottom: 1px solid var(--border); } @@ -1079,7 +1623,7 @@ font-size: var(--font-size-sm); color: var(--text-secondary); text-decoration: none; - padding: 5px 0; + padding: 5px var(--space-2); border-radius: var(--radius-sm); overflow: hidden; min-width: 0; @@ -1102,9 +1646,24 @@ background: var(--subtle); } -.sidebar-link--special { +.sidebar-meta { + padding: var(--space-3) 0; + border-top: 1px solid var(--border); + margin-top: var(--space-4); +} + +.sidebar-meta-link { + display: flex; + align-items: center; + gap: var(--space-2); font-family: var(--font-mono); - font-size: var(--font-size-sm); + font-size: var(--font-size-xs); + color: var(--muted); + padding: 3px 0; +} + +.sidebar-meta-link:hover { + color: var(--text-secondary); } .sidebar-create { @@ -1120,7 +1679,7 @@ /* Profile header */ .profile-header { - padding-top: var(--space-8); + padding-top: var(--space-10); margin-bottom: var(--space-8); } @@ -1194,6 +1753,68 @@ color: var(--text); } +.settings-agent-section { + padding: var(--space-4) var(--space-5); +} + +.settings-agent-section p { + font-size: var(--font-size-sm); + color: var(--text-secondary); + margin: 0 0 var(--space-3) 0; +} + +.settings-agent-section h3 { + font-size: var(--font-size-base); + font-weight: 600; + margin: 0 0 var(--space-1) 0; +} + +.settings-agent-divider { + border-top: 1px solid var(--border); +} + +.settings-agent-footer p { + margin: 0; + color: var(--muted); +} + +.setup-prompt { + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: var(--space-4); + margin-bottom: var(--space-3); +} + +.setup-prompt p { + margin: 0 0 var(--space-3) 0; + font-size: var(--font-size-sm); + line-height: 1.6; + color: var(--text-primary); +} + +.setup-prompt-copy { + font-size: var(--font-size-sm); + font-weight: 600; + padding: 6px 16px; + border: 1px solid var(--border); + border-radius: var(--radius-pill); + background: var(--bg-primary); + color: var(--text-secondary); + cursor: pointer; +} + +.setup-prompt-copy:hover { + color: var(--text-primary); + border-color: var(--text-secondary); +} + +.sidebar-empty { + font-size: var(--font-size-sm); + color: var(--muted); + padding: 0 var(--space-2); +} + @media (max-width: 639px) { .layout-with-sidebar { grid-template-columns: 1fr; @@ -1408,6 +2029,16 @@ gap: var(--space-3); } +.verify-hint { + font-size: var(--font-size-sm); + color: var(--muted); + margin-top: var(--space-4); +} + +.verify-hint a { + color: var(--text-secondary); +} + .shell-centered .auth-form { flex-direction: column; align-items: center; diff --git a/templates/_sidebar.html b/templates/_sidebar.html index b21377f..98a4bf2 100644 --- a/templates/_sidebar.html +++ b/templates/_sidebar.html @@ -12,6 +12,7 @@ {% endif %} {% if sidebar_special %} + + {% endif %} + {% if not sidebar_has_token %} {% endif %} {% endif %} diff --git a/templates/home.html b/templates/home.html index 8ae64a0..174acbe 100644 --- a/templates/home.html +++ b/templates/home.html @@ -1,18 +1,74 @@ {% extends "base.html" %} {% block body %} -
- {% include "_account_link.html" %} -
-

Jottit

-

Put your ideas on the web

- Create a page - {% if not signed_in %} -

no sign-up required

+
+
+ + {% if signed_in %} + {% if owner_avatar_url %}{% elif owner_initials %}{{ owner_initials }}{% endif %} + {% else %} + {% endif %} +
+
+

Markdown pages for humans and agents

+

Capture insights, refine your thinking, share when ready.
Markdown in, beautiful page out.

+ + or create a page without signing up →
+
+ +
+
+ How it works +

Write. Publish. Share.

+
+
+
1
+

Write in markdown

+

A clean split editor. Your markdown on the left, live preview on the right.

+
+
+
2
+

Hit publish

+

One click. Your page is live at jottit.org/your-slug with beautiful typography.

+
+
+
3
+

Share the URL

+

That's it. No setup, no config. Just a URL that looks great on any device.

+
+
+
+ +
+ +
+ For AI agents +

Your agent can write too

+
+

Connect Claude, ChatGPT, or any MCP-compatible agent. They create and edit pages directly.

+
# In Claude Desktop, add Jottit as an MCP server
+# Then just ask:
+
+"Save my insights about distributed systems to Jottit"
+
+# Your agent creates a page, you refine it, share when ready.
+
+
+ +
+

Ready to start?

+ + or create a page without signing up → +
{% include "_footer.html" %}
{% endblock %} -{% block head %} - -{% endblock %} diff --git a/templates/page.html b/templates/page.html index 4cc92aa..9007f12 100644 --- a/templates/page.html +++ b/templates/page.html @@ -39,6 +39,7 @@
+
+
+
+ {{ username[0] | upper }} +
+ + +
+
+ +
@{{ username }}
+
+
+
+ + Skip for now → +
+ +
+ +{% endblock %} diff --git a/templates/setup_username.html b/templates/setup_username.html new file mode 100644 index 0000000..c2dc691 --- /dev/null +++ b/templates/setup_username.html @@ -0,0 +1,24 @@ +{% extends "base.html" %} + +{% block title %}Choose your username — Jottit{% endblock %} + +{% block body %} +
+
+ + +
+

Choose your username

+

This is your home for ideas on Jottit.

+
+ +
+ jottit.org/@ + +
+

{% if error %}{{ error }}{% endif %}

+ +
+ +
+{% endblock %} diff --git a/templates/verify.html b/templates/verify.html index 7861072..b77b232 100644 --- a/templates/verify.html +++ b/templates/verify.html @@ -4,8 +4,16 @@ {% block body %}
+
+ + +

Check your email

+ {% if email %} +

We sent a 6-digit code to {{ email }}

+ {% else %}

We sent you a 6-digit code.

+ {% endif %} {% if error %}

{{ error }}

{% endif %} @@ -29,6 +37,7 @@

Check your email

+

Didn't get the code? Check your spam folder or try again.

{% endblock %} diff --git a/tests/test_api.py b/tests/test_api.py index 9b43e53..4a67e20 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -62,6 +62,62 @@ def test_get_user_profile_not_found(client): assert r.status_code == 404 +def test_agent_setup(client): + _, token = _setup_user_with_token() + r = client.get("/api/v1/agent-setup", headers=_auth(token)) + assert r.status_code == 200 + data = r.get_json() + assert "welcome" in data + assert "endpoints" in data + assert "create_page" in data["endpoints"] + assert "@" in data["welcome"] + + +def test_agent_setup_includes_philosophy_and_workflow(client): + _, token = _setup_user_with_token() + r = client.get("/api/v1/agent-setup", headers=_auth(token)) + data = r.get_json() + assert "philosophy" in data + assert "workflow" in data + assert "when_to_create" in data["workflow"] + assert "when_to_update" in data["workflow"] + assert data["workflow"]["default_visibility"] == "private" + assert "conventions" in data + + +def test_agent_setup_conventions_include_agents_content(client): + user_id, token = _setup_user_with_token() + save_page("AGENTS", "# Agents\n\nBe concise.", "unlisted", user_id) + r = client.get("/api/v1/agent-setup", headers=_auth(token)) + data = r.get_json() + agents = data["conventions"]["special_pages"]["AGENTS"] + assert agents["content"] == "# Agents\n\nBe concise." + + +def test_agent_setup_conventions_empty_without_agents(client): + _, token = _setup_user_with_token() + r = client.get("/api/v1/agent-setup", headers=_auth(token)) + data = r.get_json() + assert "AGENTS" in data["conventions"]["special_pages"] + assert "content" not in data["conventions"]["special_pages"]["AGENTS"] + + +def test_agent_setup_visibility_states_correct(client): + _, token = _setup_user_with_token() + r = client.get("/api/v1/agent-setup", headers=_auth(token)) + data = r.get_json() + body = data["endpoints"]["create_page"]["body"] + assert "private" in body["visibility"] + assert "unlisted" in body["visibility"] + assert "listed" in body["visibility"] + assert "pinned" in body["visibility"] + + +def test_agent_setup_requires_auth(client): + r = client.get("/api/v1/agent-setup") + assert r.status_code == 401 + + def test_create_page(client): _, token = _setup_user_with_token() r = client.post( @@ -73,7 +129,7 @@ def test_create_page(client): data = r.get_json() assert data["title"] == "My Page" assert data["content"] == "# My Page\n\nHello world" - assert data["visibility"] == "unlisted" + assert data["visibility"] == "private" def test_create_page_with_custom_slug(client): diff --git a/tests/test_auth.py b/tests/test_auth.py index e27f980..a05369a 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -390,12 +390,10 @@ def test_signin_page(client): # Sign-in with unknown email is rejected -def test_signin_rejects_unknown_email(client): +def test_signin_accepts_unknown_email_for_signup(client): r = client.post("/signin", data={"email": "nobody@example.com"}) - assert r.status_code == 200 - assert b"Email not found" in r.data - assert b"Create a page" in r.data - assert b"Try another email" in r.data + assert r.status_code == 302 + assert "/signin/verify" in r.headers["Location"] # Full sign-in: submit email, verify code, session contains user_id @@ -414,7 +412,7 @@ def test_signin_full_flow(client): r = client.post("/signin/verify", data={"code": code, "email": "user@example.com"}) assert r.status_code == 302 - assert r.headers["Location"] == "/" + assert r.headers["Location"] == "/setup/username" with client.session_transaction() as sess: assert "user_id" in sess @@ -434,6 +432,73 @@ def test_signin_with_username_redirects_to_profile(client): assert r.headers["Location"] == "/@signinuser" +# Full signup: new email, verify code, pick username, land on profile +def test_signup_full_flow(client): + r = client.post("/signin", data={"email": "newuser@example.com"}) + assert r.status_code == 302 + + code = create_verification_code("newuser@example.com", "signin") + r = client.post( + "/signin/verify", data={"code": code, "email": "newuser@example.com"} + ) + assert r.status_code == 302 + assert r.headers["Location"] == "/setup/username" + + r = client.get("/setup/username") + assert r.status_code == 200 + + r = client.post("/setup/username", data={"username": "newuser"}) + assert r.status_code == 302 + assert r.headers["Location"] == "/setup/profile" + + r = client.get("/setup/profile") + assert r.status_code == 200 + + r = client.post("/setup/profile", data={"name": "New User"}) + assert r.status_code == 302 + assert r.headers["Location"] == "/@newuser" + + +def test_setup_profile_skip(client): + user_id = find_or_create_user("skipper@example.com") + set_user_username(user_id, "skipper") + with client.session_transaction() as sess: + sess["user_id"] = user_id + + r = client.get("/@skipper") + assert r.status_code == 200 + + +def test_setup_username_requires_signin(client): + r = client.get("/setup/username") + assert r.status_code == 302 + assert "/signin" in r.headers["Location"] + + +def test_setup_username_rejects_taken(client): + user_id = find_or_create_user("existing@example.com") + set_user_username(user_id, "taken") + + new_id = find_or_create_user("newperson@example.com") + with client.session_transaction() as sess: + sess["user_id"] = new_id + + r = client.post("/setup/username", data={"username": "taken"}) + assert r.status_code == 200 + assert b"already taken" in r.data + + +def test_setup_username_skips_if_already_set(client): + user_id = find_or_create_user("hasname@example.com") + set_user_username(user_id, "hasname") + with client.session_transaction() as sess: + sess["user_id"] = user_id + + r = client.get("/setup/username") + assert r.status_code == 302 + assert "/@hasname" in r.headers["Location"] + + # Invalid sign-in code shows an error def test_signin_invalid_code(client): find_or_create_user("user@example.com") @@ -471,28 +536,16 @@ def test_homepage_shows_signin_when_logged_out(client): assert b"Sign in" in r.data -# Logged-in homepage shows settings link instead of sign in +# Logged-in homepage shows avatar instead of sign in def test_homepage_shows_avatar_when_logged_in(client): user_id = find_or_create_user("logged@example.com") with client.session_transaction() as sess: sess["user_id"] = user_id r = client.get("/") - assert b"/settings" in r.data + assert b"Markdown pages for humans and agents" in r.data assert b"Sign in" not in r.data - - -# -- Homepage pages list -- - - -# Homepage doesn't show a "My pages" link -def test_homepage_no_my_pages_link(client): - user_id = find_or_create_user("pages@example.com") - with client.session_transaction() as sess: - sess["user_id"] = user_id - - r = client.get("/") - assert b"My pages" not in r.data + assert b"home-header-avatar-link" in r.data # -- Auto-claim for signed-in users -- diff --git a/tests/test_pages.py b/tests/test_pages.py index 647b306..c3ea290 100644 --- a/tests/test_pages.py +++ b/tests/test_pages.py @@ -266,7 +266,7 @@ def test_homepage_logged_out_shows_landing(client): r = client.get("/") body = r.data.decode() assert "tab--active" not in body - assert "Create a page" in body + assert "Sign up for Jottit" in body # /pages requires sign-in diff --git a/tests/test_revisions.py b/tests/test_revisions.py index e73cd88..f006aa4 100644 --- a/tests/test_revisions.py +++ b/tests/test_revisions.py @@ -62,6 +62,19 @@ def test_view_revision_nonexistent(client): assert r.status_code == 404 +# Private page revision is not accessible to non-owners +def test_view_revision_private_blocked(client): + from db import find_or_create_user, set_user_username + + user_id = find_or_create_user("revpriv@test.com") + set_user_username(user_id, "revprivuser") + save_page("secretrev", "# Secret\n\nHidden content", "private", user_id) + with client.session_transaction() as sess: + sess.clear() + r = client.get("/@revprivuser/secretrev/history/1") + assert r.status_code == 404 + + # -- Change descriptions -- diff --git a/tests/test_subdomain.py b/tests/test_subdomain.py index 760ef64..9cd1b28 100644 --- a/tests/test_subdomain.py +++ b/tests/test_subdomain.py @@ -116,11 +116,21 @@ def test_old_slug_redirects_unclaimed_page(client): # -- Visibility -- -# New pages default to "listed" visibility (via create_user_with_username) -def test_visibility_default_is_listed(client): - user_id = create_user_with_username(client, "list1@example.com", "listuser1", "lp1") - page_meta = get_page_meta("lp1", user_id) - assert page_meta["visibility"] == "listed" +# New pages by signed-in users default to "private" visibility +def test_visibility_default_is_private(client): + from db import find_or_create_user, set_user_username + + user_id = find_or_create_user("list1@example.com") + set_user_username(user_id, "listuser1") + with client.session_transaction() as sess: + sess["user_id"] = user_id + client.post( + "/@listuser1/newpage/edit", + data={"content": "# Test\n\nContent"}, + ) + page_meta = get_page_meta("newpage", user_id) + assert page_meta is not None + assert page_meta["visibility"] == "private" # Owner can change a page's visibility to "unlisted" @@ -134,6 +144,51 @@ def test_update_visibility(client): assert page_meta["visibility"] == "unlisted" +# Private pages are blocked for non-owners +def test_private_page_blocked_for_non_owner(client): + from db import update_page_visibility + + user_id = create_user_with_username( + client, "priv@example.com", "privuser", "privpage" + ) + page_meta = get_page_meta("privpage", user_id) + update_page_visibility(page_meta["id"], "private") + with client.session_transaction() as sess: + sess.clear() + r = client.get("/@privuser/privpage") + assert r.status_code == 404 + + +# Private pages are accessible to the owner +def test_private_page_accessible_for_owner(client): + from db import update_page_visibility + + user_id = create_user_with_username( + client, "privo@example.com", "privowner", "privownpage" + ) + page_meta = get_page_meta("privownpage", user_id) + update_page_visibility(page_meta["id"], "private") + with client.session_transaction() as sess: + sess["user_id"] = user_id + r = client.get("/@privowner/privownpage") + assert r.status_code == 200 + + +# Private pages don't appear on the profile for visitors +def test_private_page_hidden_from_profile(client): + from db import update_page_visibility + + user_id = create_user_with_username( + client, "privh@example.com", "privhidden", "privhpage" + ) + page_meta = get_page_meta("privhpage", user_id) + update_page_visibility(page_meta["id"], "private") + with client.session_transaction() as sess: + sess.clear() + r = client.get("/@privhidden") + assert b"privhpage" not in r.data + + # Unlisted pages don't appear on the profile homepage def test_unlisted_page_hidden_from_profile(client): user_id = create_user_with_username(client, "list3@example.com", "listuser3", "lp3") @@ -286,3 +341,47 @@ def test_subdomain_redirects_to_profile(client): r = client.get("/lp1", headers={"Host": "legacyuser.jottit.localhost:8000"}) assert r.status_code == 301 assert "/@legacyuser" in r.headers["Location"] + + +# INDEX.md: page with slug "index" renders as profile content +def test_index_page_renders_on_profile(client): + user_id = find_or_create_user("idx@example.com") + set_user_username(user_id, "idxuser") + save_page("index", "# Welcome\n\nThis is my profile.", "listed") + page_meta = get_page_meta("index") + claim_page(page_meta["id"], user_id) + r = client.get("/@idxuser") + assert r.status_code == 200 + assert b"This is my profile" in r.data + + +# INDEX.md: private index page visible to owner but not visitors +def test_index_page_private_hidden_from_visitors(client): + from db import update_page_visibility + + user_id = find_or_create_user("idxp@example.com") + set_user_username(user_id, "idxpriv") + save_page("index", "# Secret\n\nPrivate index.", "listed") + page_meta = get_page_meta("index") + claim_page(page_meta["id"], user_id) + update_page_visibility(page_meta["id"], "private") + with client.session_transaction() as sess: + sess.clear() + r = client.get("/@idxpriv") + assert b"Private index" not in r.data + + +# INDEX.md: private index page visible to owner +def test_index_page_private_visible_to_owner(client): + from db import update_page_visibility + + user_id = find_or_create_user("idxo@example.com") + set_user_username(user_id, "idxown") + save_page("index", "# My Index\n\nOwner sees this.", "listed") + page_meta = get_page_meta("index") + claim_page(page_meta["id"], user_id) + update_page_visibility(page_meta["id"], "private") + with client.session_transaction() as sess: + sess["user_id"] = user_id + r = client.get("/@idxown") + assert b"Owner sees this" in r.data