Commit Graph

274 Commits

Author SHA1 Message Date
xarmian ecea75dcd8 refactor(web): extract shared FieldEditor component (#133)
Pull the field-card UI out of EditCollectionModal into a reusable
FieldEditor.svelte so existing and new fields render identically.
Foundation for PLAN-593 (Collection Modal Redesign, TASK-594).

- Add FieldEditor.svelte with its own scoped styles for the field
  card, reorder buttons, select/multi_select options editor, and
  status-field terminal toggle.
- Add field-editor-types.ts with the shared EditableField interface,
  FIELD_TYPES list, and a blankField() factory.
- EditCollectionModal adopts FieldEditor for both existing and new
  (unsaved) fields, replacing the stripped-down comma-separated row
  form.
- Merge the two field lists into one visual container so new fields
  flow continuously with existing ones; drop the horizontal divider.
- New fields gain reorder buttons within the new-fields list as a
  natural consequence of the unification.
- Save logic, migration building, and status-terminal behavior are
  preserved exactly. Key-from-label derivation for new fields now
  reads from 'label' (was 'key'), maintaining identical user-visible
  behavior until TASK-595 introduces slugification.
2026-04-17 12:38:49 -04:00
xarmian fed2766424 Merge pull request #132 from xarmian/fix/open-bugs-batch-585-586-588-589-590
fix: resolve five open bugs (BUG-585, 586, 588, 589, 590)
2026-04-17 00:41:45 -04:00
xarmian be0ae3d8f5 Revert "fix: accept password confirmation at unlink for unupgraded users"
This reverts commit c94fc8dd6b.
2026-04-17 04:41:35 +00:00
xarmian c94fc8dd6b fix: accept password confirmation at unlink for unupgraded users
BUG-588 follow-up: users who signed up with email/password and later
linked a single OAuth provider are backfilled as password_set=false
(because the backfill only flags users with no OAuth linked), and if
they're currently logged in via OAuth they can't complete the
ValidatePassword path that would flip the bit. They remain blocked
from unlinking that only provider.

Accept an optional password in the /auth/oauth-unlink request body.
When the user has no other sign-in method and password_set is still
false, the handler verifies the supplied password via ValidatePassword
(which also upgrades password_set on success) and then allows the
unlink. No password supplied → same "cannot unlink your only sign-in
method" error as before, with a slightly more actionable message.
2026-04-17 04:37:11 +00:00
xarmian 311067ade1 fix: fall through to legacy title lookup when ref-shaped key misses
A body like `[[ISO-9001]]` or `[[BUG1-5]]` matches REF_PATTERN but may
legitimately be a pre-existing title-based wiki-link (the ref format is
just PREFIX-NUMBER, which overlaps with plausible real titles). The
previous commit returned the raw match immediately on a failed ref
lookup, effectively dropping any ref-shaped legacy titles.

Change the ref branch to fall through to the legacy title / collection
matching paths when no item ref matches. Canonical `[[BUG-585]]` → BUG-586
still resolves correctly because the ref branch wins whenever the ref
exists; only the miss case now continues searching.
2026-04-17 04:24:33 +00:00
xarmian a4496849dc fix: prioritize REF lookup over full-body title match
Codex flagged a precedence inversion: the earlier full-body-first checks
ran before REF_PATTERN, so a canonical ref link like `[[BUG-585]]` would
silently retarget onto a user item whose title happened to match the ref
literal (case-insensitive). Ref storage is now our canonical form via
markdownToWikiLinks, so this path must be deterministic.

Restructure the resolver so ref lookup is always checked first. The
legacy full-body title / collection-qualified title checks only run on
bodies that actually need them — i.e. when the body contains a pipe
(which is the only condition that motivated the full-body check in the
first place: recovering pre-existing "[[A|B]]" / "[[coll/A|B]]" titles).
2026-04-17 04:14:59 +00:00
xarmian 922cb26e1d fix: preserve legacy [[coll/Title]] links whose titles contain |
Follow-up to the previous commit: the full-body title lookup handled the
plain-title case, but collection-qualified legacy links like
`[[tasks/A|B]]` (where the item's real title is "A|B" in "tasks") were
still split on the pipe before the collection/title resolution ran, so
the lookup was attempted for title "A" and the link rendered as plain
text. Add a full-body collection-qualified lookup alongside the full-body
title lookup, both before the `key|display` split.
2026-04-17 04:04:57 +00:00
xarmian de21b1199d fix: preserve legacy [[Title]] links whose titles contain |
Codex flagged a behavioral regression in `wikiLinksToMarkdown`: splitting
the wiki body on the first unescaped `|` unconditionally meant a legacy
link like `[[A|B]]` — where the item's actual title is literally `A|B` —
would be parsed as `key=A, display=B`, fail to resolve, and render as
plain text.

Fix by attempting an exact full-body title match (with `|` intact) before
falling through to the `key|display` split. This keeps the new ref-based
forms (`[[REF]]`, `[[REF|Display]]`) working while recovering pre-existing
content whose titles were never escape-encoded.
2026-04-17 03:55:23 +00:00
xarmian d1a5ea3975 fix: robust wiki-link round-trip and navigable popover (BUG-586 follow-ups)
Follow-up to the BUG-586 fix that surfaced several edge cases under
real-world use. Covers three related improvements to the wiki-link
experience in the editor.

Reference-based wiki-link storage
  Previously `[Title](/url)` round-tripped to `[[Title]]`. That form
  broke for titles containing `[`, `]`, `/`, or `|`. Storage now uses
  the item's opaque ref (e.g. `[[BUG-586]]`, or `[[BUG-586|Custom]]`
  when the visible text differs from the item's current title).
  `wikiLinksToMarkdown` accepts three forms in preference order:
  ref-only, ref-with-display-override, and legacy title lookup.
  Titles can now contain any characters and links survive renames.

Escape-aware parsing
  Both `markdownToWikiLinks` and `wikiLinksToMarkdown` now recognize
  `\.` escape sequences inside their capture groups. tiptap-markdown
  emits `\[`, `\]`, `\\` in link text when the text contains literal
  brackets, so the prior regexes (`[^\]]+`) terminated prematurely and
  missed valid links. Helper functions escape/unescape the markdown
  link-text layer and the wiki-link body layer separately so `]`, `|`,
  and `\` can appear in display-override text.

Leave unresolved [[X]] untouched
  The `[[…]]` regex is greedy and can match spans that were never
  intended as wiki-links — notably `[[` sequences inside another
  markdown link's text. On miss, the function now returns the original
  match verbatim instead of emitting `[…](broken)`, which previously
  hijacked surrounding content and accumulated corruption on each
  save cycle. Broken items heal themselves on the next auto-save.

Picker: show ref + align URL with the route
  The `[[` picker now lists the ref badge next to the title and keys
  `{#each}` by `doc.id` so duplicate titles don't collide. `execLink`
  now reads `page.params.username`/`page.params.workspace` from the
  live route (previously `workspaceStore.current`, which could be
  empty), so the inserted `href` matches the URL shape that the
  round-trip expects.

Clickable link popover
  The popover's URL label is now a real `<a href="…">`. Plain click →
  `goto()` for internal paths, full navigation for external. Ctrl /
  Cmd / middle-click pass through to the browser so "new tab" and
  "copy link" work naturally. `onmousedown.stopPropagation` keeps the
  outer popover's focus-trap from swallowing the click.
2026-04-17 03:44:41 +00:00
xarmian e328844a1b fix: resolve five open bugs (BUG-585, BUG-586, BUG-588, BUG-589, BUG-590)
BUG-585 — Code-block copy no longer includes ``` fences
  Editor.svelte: ProseMirror plugin overrides copy/cut when the selection
  is inside a code_block node and writes raw textBetween to the clipboard.
  NodeView for non-mermaid code blocks now shows a hover "Copy" button that
  uses the existing copyToClipboard() util (with execCommand fallback).

BUG-586 — Wiki-link picker matches on item ref
  Editor.svelte: getFilteredLinks() now also matches formatItemRef(item),
  so typing [[DOC-535]] finds items by their issue ID. Picker dropdown
  shows the ref as a badge; {#each} key switched to doc.id so duplicate
  titles across collections don't collide.

BUG-588 — Can unlink OAuth provider when password is configured
  Adds a password_set column to track whether a user has a usable
  password vs. the random placeholder hash given to OAuth users.
  CreateUser sets it true, UpdateUser sets it true when a password is
  provided, and ValidatePassword auto-upgrades it on any successful
  email/password login (which transparently upgrades pre-existing users
  who linked OAuth after signing up with a real password — the OAuth
  placeholder hash cannot match user-supplied plaintext, so this is safe).
  handleOAuthUnlink now permits removing the last provider when
  user.HasPassword() is true.

BUG-589 — Pre-auth pages render standalone
  +layout.svelte: isAuthPage now also matches /forgot-password and
  /reset-password/* so those pages don't inherit the authenticated
  sidebar/topbar layout.

BUG-590 — Search no longer crashes with null results
  store.Search() returned a nil Results slice on no-match queries, which
  Go marshals as JSON null; CommandPalette then crashed on results.length.
  Backend now normalizes nil to []SearchResult{} before returning.
  CommandPalette also coalesces resp.results ?? [] on the initial search
  and loadMore paths as belt-and-suspenders hardening.
2026-04-17 03:03:49 +00:00
xarmian e530e5f1ab fix: force full navigation for OAuth link buttons (#131)
The "Link GitHub"/"Link Google" buttons on /console/settings and the OAuth
sign-in buttons on /login were plain <a href="/auth/..."> tags. SvelteKit
intercepted the clicks and did client-side navigation, so the request
never hit the nginx router and pad-cloud never saw it. SvelteKit would
then try to match /auth/github/link against the [workspace]/[collection]
route, 404 on the API calls, and render "Collection not found".

Add data-sveltekit-reload so the browser performs a real HTTP navigation
and the nginx router can forward /auth/* to the pad-cloud sidecar.
2026-04-16 16:41:39 -04:00
xarmian 55a779d838 fix: load workspaces reactively after post-auth navigation (BUG-584) (#130)
* fix: load workspaces reactively after post-auth navigation (BUG-584)

Root layout only loaded workspaceStore inside onMount, guarded by
!isAuthPage. When a user first lands on /login, onMount skips the load
(isAuthPage is true). After login, goto('/console') does a client-side
navigation — the root layout's onMount does NOT re-run, so the
workspace store stays empty. When the user then opens a workspace,
<TopBar /> renders with a blank workspace list until a hard refresh.

The same failure mode affects register, join, reset-password, and any
other post-auth redirect path, since all of them mount the root layout
on an auth page first.

Fix: replace the onMount-local loadAll() call with a reactive $effect
that fires whenever the user is authenticated, the page is an app page
(not auth/share), and the store hasn't been loaded yet. A
workspacesLoaded latch prevents re-firing for users who legitimately
have zero workspaces.

Also fix a broken template-literal in the mobile header <a href>:
\${workspaceStore.current?.slug} was a JS template-literal in a plain
HTML attribute string, which Svelte renders as a literal '$'. Changed
to Svelte's {...} attribute interpolation.

* fix: drop authStore.authenticated gate from workspace loader effect

Addresses Codex P1 feedback on #130. The onMount auth-check block
intentionally swallows authStore.load() failures with a comment
explaining the server may not support auth. Gating the $effect on
authStore.authenticated therefore regressed the original !isAuthPage
behavior for deployments where /api/v1/auth/session is unavailable:
the effect never fired and the workspace list stayed empty.

Remove that one gate. The onMount flow still redirects unauthenticated
users to /login before authReady flips true, so by the time the effect
runs on an app page we're either authenticated or auth is
unsupported/errored — both cases should load workspaces, matching the
original behavior. Comment updated to document why.

* fix: skip workspace load during logged-out-to-/login redirect window

Addresses second Codex P1 on #130. The previous commit dropped the
authStore.authenticated gate from the workspace loader effect, which
fixed auth-unsupported deployments but introduced a new regression:
authReady is set to true inside the !auth / setup_required /
!auth.authenticated branches of onMount *before* goto('/login')
completes. For a logged-out user who first hits a protected route,
during that window the effect saw authReady=true and isAuthPage=false
(still on the protected URL), fired loadAll() (silently 401), and
latched workspacesLoaded=true — blocking the retry after login.

Gate on (authStore.authenticated || authLoadFailed) where
authLoadFailed is set only in the catch branch. This preserves both
the original backward-compat for auth-unsupported deployments and the
correct skip-during-redirect behavior, without coupling the effect
to the rest of the redirect machinery.
2026-04-16 15:45:30 -04:00
xarmian fb56ada1cd fix: align Starred page layout with sibling pages (BUG-579) (#129)
The Starred page used hardcoded layout values (max-width: 800px,
asymmetric padding, vertically-stacked header, smaller h1) while
every other workspace page uses the shared design tokens. Swap to
the canonical pattern from activity/+page.svelte so the page feels
consistent with Activity, Conventions, Settings, etc.

- max-width: 800px -> var(--content-max-width) (960px)
- padding: var(--space-6) var(--space-6) var(--space-12) -> var(--space-8) var(--space-6)
- .page-header: flex row with justify-content: space-between
- .header-top renamed to .page-header-left; redundant margin-bottom removed
- h1 font-size: 1.5em -> 1.6em
2026-04-16 14:03:49 -04:00
xarmian bf7901ab29 feat: add facet summary to CLI search output (#128)
Show collection breakdown (e.g. "docs: 9, ideas: 8, tasks: 53")
after the result count when searching across all collections.
Hidden when filtering by a specific collection.
2026-04-15 12:33:37 -04:00
xarmian 91920c9085 feat: add collection-level search with Cmd+F (#127)
* feat: add collection-level search with Cmd+F

Intercept Cmd+F / Ctrl+F on collection pages to focus the search
input instead of opening browser search. Replace client-side substring
filtering with API-backed FTS search scoped to the collection, with
200ms debounce and instant client-side fallback while the API responds.

- Cmd+F / Ctrl+F focuses the FilterBar search input
- Escape clears search and blurs the input
- Search uses /search?collection=<slug> for full-text matching
- Client-side filter used as fallback during API debounce
- searchResultIds cleared on all filter/view reset paths
- FilterBar exposes searchInputEl via $bindable prop

* fix: open filters panel on Cmd+F and guard stale search responses

- Open filtersOpen panel before focusing search input so it exists
  in the DOM; use requestAnimationFrame to wait for mount
- Snapshot query before async search and discard response if query
  changed while loading

Addresses codex review on PR #127.

* fix: route Cmd+F through layout keydown handler via UI store

The collection page's svelte:window onkeydown couldn't reliably
intercept Cmd+F because the layout already registers the window
keydown handler. Move Cmd+F handling to the layout's handler and
dispatch via a uiStore.collectionSearchRequested signal that the
collection page watches with $effect.

* fix: clear stale search IDs immediately on new query

Set searchResultIds to null as soon as a new query arrives so the
client-side fallback filter kicks in immediately while the API
debounce is pending. Previously stale IDs from the prior query
would persist during the 200ms gap.

Archived filtering and limit concerns are already handled by the
existing filteredItems pipeline which applies field/status filters
on top of search results.

Addresses codex review on PR #127.
2026-04-15 09:26:17 -04:00
xarmian 9616374a80 feat: upgrade CommandPalette with filters, grouping, and pagination (#126)
* feat: upgrade CommandPalette with filters, grouping, and pagination

Rewrite the Cmd+K search modal as a full-featured search experience:

- Filter chips: collection and status filters from facets, toggle on click
- Grouped results: items grouped by collection with section headers
- Better result cards: ref, title, priority dot, status badge, relative date
- Pagination: "Load more" button appends next page of results
- Recent searches: last 10 queries persisted in localStorage
- Result count displayed below filter chips

* fix: reset loading state on empty query and guard stale loadMore

- Clear loading flag when query is emptied so spinner doesn't stick
- Capture query/filter snapshot before loadMore API call and discard
  the response if they changed while loading

Addresses codex review on PR #126.
2026-04-15 08:10:04 -04:00
xarmian 8351b8194f feat: add faceted counts to search results (#125)
* feat: add faceted counts to search results

Add collection and status faceted counts to SearchResponse so the
frontend can show breakdowns like "Tasks (24) · Ideas (4)". Facets
reflect the full unpaginated result set via two GROUP BY queries
using the same filters as the main search.

- Add SearchFacets type with collections and statuses maps
- Add searchFacets() method using appendSearchFilters for consistency
- Add SearchFacets TypeScript type to frontend
- Add TestSearchFacets covering counts and pagination independence

* fix: include ref-hit items in facet counts

Ref hits (e.g. searching "TASK-42") bypass FTS and wouldn't appear
in FTS-based facet aggregation. Merge them into facets after the
facet queries run so collection/status counts include all results.

Addresses codex review on PR #125.

* fix: remove ref-hit facet merge to avoid double-counting

Unconditionally adding ref hits to facets overcounts when the item
is also found by FTS (the common case). Since we can't cheaply
detect overlap, leave facets as FTS-only. Ref searches typically
return 1 exact match, so the off-by-one is acceptable.

Addresses codex review on PR #125.
2026-04-15 07:37:07 -04:00
xarmian 3e51bfa541 fix: rewrite search ref-hit pagination for correctness (#124)
* fix: rewrite search ref-hit pagination for correctness

The previous ref-hit pagination logic had cascading issues: clearing
results on offset>0 broke the seen map, total was wrong for ref
queries on later pages, and multi-ref hits were dropped entirely.

Rewrite the approach:
- Save ref hits and their IDs before FTS query runs
- Ref hits always appear on page 0; FTS limit reduced accordingly
- On pages after 0, ref hits excluded and FTS offset adjusted
- Track FTS deduplication to correct total (avoid double-counting)
- Total is always >= actual result count as a safety floor
- All ORDER BY clauses include i.id tie-breaker for stable pagination

Addresses all 6 codex review comments on PR #123.

* fix: simplify ref-hit total calculation

Remove the refCount add / ftsDeduped subtract dance which was
inherently broken across pages. Instead, use a simple floor:
total is always at least len(results). The FTS count is accurate
for FTS results; ref-only hits (rare) just bump the floor.

Addresses codex review on PR #124.
2026-04-15 00:04:51 -04:00
xarmian 999bd3cfca feat: add pagination and sorting to search API (#123)
* feat: add pagination and sorting to search API

Extend the search endpoint with limit/offset pagination and sort options.
The response now includes total count (from a separate count query) so
frontends can paginate properly.

- Add Limit, Offset, Sort, Order to SearchParams with Normalize() defaults
- Return SearchResponse struct with total/limit/offset metadata
- Count query runs alongside results query for accurate totals
- Sort options: relevance (default), created_at, updated_at, title
- Add --sort, --limit, --offset flags to CLI search command
- Update frontend SearchFilters and SearchResponse types
- Add TestSearchPagination and TestSearchSorting integration tests

* fix: count ref hits in search totals and handle empty pages

- Ensure total is never less than actual results when direct ref
  matches (e.g. "TASK-5") aren't captured by the FTS count query
- Handle empty page in CLI output: show "No results on this page"
  instead of an invalid descending range like "Showing 11-10 of 5"

Addresses codex review on PR #123.

* fix: paginate ref hits correctly and add sort tie-breaker

- Ref hits now occupy slots on page 0 only; FTS limit/offset adjusted
  so combined results respect the requested pagination contract
- On subsequent pages, ref hits are excluded (already shown on page 0)
- Add i.id as deterministic tie-breaker to all ORDER BY clauses to
  prevent duplicate/missing items across paginated pages

Addresses codex review on PR #123.
2026-04-14 23:40:23 -04:00
xarmian aef0e2326a feat: add collection and field filtering to search API (#122)
* feat: add collection and field filtering to search API

Extend the /search endpoint to support scoping by collection slug and
filtering by structured field values (status, priority, and generic
field.* params). Works on both SQLite FTS5 and PostgreSQL tsvector.

- Add Collection and FieldFilters to SearchParams (store layer)
- Parse collection, status, priority, field.* query params (handler)
- Add SearchFilters type and update api.search() signature (frontend)
- Add --collection, --status, --priority flags to CLI search command
- Add integration tests for collection, field, and combined filtering

* fix: validate field filter keys to prevent SQL injection

Reject field filter keys containing special characters before they
reach JSONExtractText, which interpolates keys directly into SQL.
Keys must match ^[a-zA-Z][a-zA-Z0-9_-]*$ — validation is applied
in both the handler and the store layer as defense in depth.

Addresses codex review on PR #122.
2026-04-14 22:31:18 -04:00
xarmian c5183d2a4b feat: add SSE events for item star/unstar (#121)
* feat: add SSE events for item star/unstar

Emit real-time events for multi-tab sync (PLAN-564, TASK-571):

- New event types: item_starred, item_unstarred
- Emitted from handleStarItem and handleUnstarItem after success
- Includes item ID, title, collection, actor, and source
- Follows existing publishItemEventWithName pattern

* fix: scope star/unstar SSE events to the acting user

Star events are user-specific state, not workspace-wide. Changes:

- Add UserID field to Event struct for user-scoped events
- SSE handler filters events with UserID, only delivering them to
  the user who triggered the action (multi-tab sync without leaking
  star actions to other workspace members)
- Star/unstar handlers set UserID when publishing events
2026-04-14 21:03:37 -04:00
xarmian 9072e49b17 feat: add CLI commands for item starring (#120)
Add star/unstar/starred CLI commands (PLAN-564, TASK-570):

- pad item star <ref> — star an item
- pad item unstar <ref> — unstar an item
- pad item starred [--all] [--format json] — list starred items

Client methods: StarItem, UnstarItem, ListStarredItems.
2026-04-14 20:51:03 -04:00
xarmian f7d93d878d feat: add starred items to dashboard (#119)
* feat: add starred items to dashboard

Add starred items section to the dashboard (PLAN-564, TASK-569):

- Dashboard API: fetches non-terminal starred items for the current user,
  applies RBAC visibility filtering, returns as starred_items array
- TypeScript types: add starred_items to DashboardResponse
- Dashboard UI: renders starred items section with card grid, item refs,
  status pills, and "View all" link to /starred page

* fix: cap starred items in dashboard response to 10

Match the same limit applied to active_items, preventing large payloads
on the polling dashboard endpoint for users with many starred items.
2026-04-14 20:31:37 -04:00
xarmian 77d3fe2d72 feat: add Starred sidebar entry and starred items page (#118)
* feat: add Starred sidebar entry and starred items page

Add dedicated starred items view (PLAN-564, TASK-568):

- Sidebar: new " Starred" link below Activity, with active state
- Starred page: shows user's starred items grouped by collection
- "Show completed" toggle to include/exclude terminal status items
- Loading skeleton, empty state with usage instructions
- Excludes "starred" and "roles" from collection slug detection

* fix: reactively remove unstarred items, guard against stale responses

Two fixes for the starred page:

1. Items list is now derived from starredStore.isStarred, so unstarring
   an item via the ItemCard toggle immediately removes it from the page
   without a refetch.

2. Request sequencing via loadSeq counter prevents stale responses from
   overwriting the UI when rapidly toggling "Show completed".

* fix: reserve collection slugs that collide with workspace UI routes

Prevent collections from being created or renamed to slugs that shadow
workspace-level routes (settings, activity, roles, starred, library,
new). If a reserved slug is generated, "-collection" is appended
(e.g. "starred" becomes "starred-collection").

Also fixes starred page: items list is now reactive to unstar actions,
and loadStarred uses request sequencing to prevent stale responses.

* fix: skip store filter on starred page until store is loaded

Trust the API response when starredStore hasn't loaded yet, since
/starred only returns starred items. Apply the reactive filter only
after the store is loaded, so unstar actions still remove items
immediately but initial render isn't broken by async timing.
2026-04-14 20:15:49 -04:00
xarmian 5d23791c1d feat: add star toggle to web UI item views (#117)
* feat: add star toggle to web UI item views

Add per-user item starring to the web UI (PLAN-564, TASK-567):

- API client: star(), unstar(), starStatus(), starred() methods
- Starred store: loads starred IDs on workspace init, optimistic toggle
- ItemCard: star button (☆/★) in top-left, hidden until hover, always
  visible when starred, amber color
- Item detail page: star button in meta-actions header row
- Workspace layout: loads starred store on workspace change

* fix: guard starred store against stale workspace responses

Add a monotonic request counter so that if a user switches workspaces
quickly, an older in-flight response won't overwrite the current
workspace's starred state. Also guards toggle revert against workspace
changes.

* fix: merge in-flight toggles with load result in starred store

Track toggles that occur while the initial load is in-flight via a
pendingToggles map. When the load completes, merge local mutations
on top of the server response so optimistic updates aren't overwritten.
Reverts also update the pending map for consistency.

* fix: preserve toggles on load error, serialize per-item toggles

Two fixes for starred store edge cases:

1. Error path now applies pendingToggles instead of resetting to empty,
   so optimistic toggles survive a failed initial load.

2. Per-item toggle lock (toggleInFlight set) drops rapid duplicate
   clicks while a toggle API call is in flight, preventing out-of-order
   requests from producing inconsistent state.

* fix: clear stale stars immediately on workspace load

Reset starredIds at the start of load() before the async fetch, so a
previous user/workspace's stars are never briefly visible during SPA
navigation or re-authentication flows.
2026-04-14 19:27:52 -04:00
xarmian 844e40f0a9 feat: add star/unstar API endpoints (#116)
* feat: add star/unstar API endpoints

Add REST API for item starring (PLAN-564, TASK-566):

- POST /workspaces/{ws}/items/{slug}/star — star item (idempotent, 204)
- DELETE /workspaces/{ws}/items/{slug}/star — unstar item (204 or 404)
- GET /workspaces/{ws}/items/{slug}/star — check star status ({"starred": bool})
- GET /workspaces/{ws}/starred — list starred items (?include_terminal=true)

All endpoints are scoped to the authenticated user, check item visibility
via RBAC/grants, and enrich list responses with parent links and refs.

* fix: enforce RBAC visibility filtering on starred items list

Apply the same collection/item grant filtering used by handleListItems
to handleListStarredItems. Without this, guests or restricted members
could see starred items from collections they no longer have access to.
2026-04-14 18:15:19 -04:00
xarmian d372be6aff feat: add item_stars table and store methods (#115)
* feat: add item_stars table and store methods for per-user item starring

Add the data layer for item starring/favorites (PLAN-564, TASK-565):

- Migration 042 (SQLite) / 022 (PostgreSQL): item_stars join table with
  (user_id, item_id) primary key, ON DELETE CASCADE, and indexes
- Store methods: StarItem, UnstarItem, IsItemStarred, AreItemsStarred
  (batch), ListStarredItems (enriched), CountStarredItems, DeleteStarsForItem
- 8 tests covering CRUD, idempotency, per-user isolation, and batch ops

* fix: implement includeTerminal filter in ListStarredItems

The includeTerminal parameter was accepted but unused — starred items
in terminal statuses (done, completed, etc.) were always returned.
Now post-filters using IsTerminalStatusDefault, matching the pattern
used by GetRoleBoardItems. Adds test coverage for the filter.

* fix: use per-collection schemas for terminal filtering, cascade user deletes

Addresses two code review findings:

1. Terminal filtering now loads collection schemas and uses
   IsTerminalStatus per collection instead of IsTerminalStatusDefault.
   This correctly handles custom terminal statuses (e.g. "closed").

2. Added ON DELETE CASCADE to the user_id foreign key in both SQLite
   and PostgreSQL migrations, so deleting a user automatically cleans
   up their stars.

* perf: use lightweight query for collection schema loading

Replace ListCollections call in buildCollectionSchemaMap with a direct
SELECT of only id and schema columns. ListCollections runs per-collection
COUNT(*) queries for ActiveItemCount which are unnecessary here.
2026-04-14 17:50:48 -04:00
xarmian 6a552b83ae fix: link comment to activity record to prevent duplicate timeline entries (#114)
* fix: link comment to activity record to prevent duplicate timeline entries (#BUG-563)

When creating a comment, the handler created a comment (activity_id=NULL) and a
separate "commented" activity but never linked them. The timeline dedup logic only
filters activities that have a linked comment, so both showed up as separate entries.

Fix: create the activity first via logActivityWithMetaReturningID, then pass its ID
to CreateComment so buildTimeline correctly deduplicates them.

* fix: only link activity ID to comment when activity insert succeeds

Address review feedback: CreateActivity assigns an ID before Exec and
returns it even on insert failure. Since comments.activity_id has a FK
constraint, setting a dangling reference would break comment creation.
Now we only set ActivityID when the activity was actually persisted.
2026-04-14 16:21:21 -04:00
xarmian b620b7e5cc feat: add edit button to collection detail page header (#113)
* feat: add edit button to collection detail page header

Wire up the existing EditCollectionModal to the collection detail page
with a pencil icon button in the header actions bar. Owner-only,
responsive (icon-only on mobile), refreshes page data after saves.

Closes IDEA-494

* fix: navigate to new slug after collection rename in edit modal

When a collection is renamed, the backend regenerates the slug. The
onupdated callback now receives the updated Collection object, so the
collection page can detect a slug change and navigate to the new URL
instead of 404ing on the old slug.

Addresses PR review feedback from #113.

* fix: refresh collectionStore after edit modal update

Ensures the sidebar reflects updated collection name/slug/icon
immediately after editing, matching the settings page behavior.

Addresses P2 review feedback from #113.
2026-04-14 14:37:45 -04:00
xarmian aeda627320 fix: sidebar collection drag-and-drop only moving by one slot (#111)
Fixed race condition where $effect reset sidebarCollections mid-update
because isDraggingSidebar was cleared before API calls completed.

- Capture reordered array locally; keep drag flag true during updates
- Fire all sort_order updates in parallel with Promise.all
- Wrap in try/finally so drag state always resets on failure
- Guard reset with generation counter to prevent stale finalize from
  clobbering a newer drag operation

Fixes BUG-562
2026-04-14 11:19:06 -04:00
xarmian 75bed01701 feat: sticky breadcrumb header with copy item ID button (#112)
* feat: sticky breadcrumb header with copy item ID button

Make the breadcrumb navigation bar sticky so it stays visible when
scrolling on item detail pages. Add a copy-to-clipboard button next
to the item ref (e.g. TASK-5) that shows a "Copied!" tooltip on click.
Uses the existing copyToClipboard utility with non-SSL fallback.

Closes IDEA-501

* fix: lower sticky breadcrumb z-index below mobile header

The mobile layout has a sticky header at z-index 5. Lower the
breadcrumb's z-index to 4 so it slides under the mobile header
instead of overlapping navigation controls.

* fix: offset sticky breadcrumb below mobile header instead of hiding it

Instead of lowering z-index (which hides the breadcrumb behind the
mobile header), offset it with top: 45px on mobile so it stacks
neatly below the mobile nav. Keeps z-index: 10 so the sticky
effect works properly on all screen sizes.
2026-04-14 10:06:27 -04:00
xarmian 1fb50d4f62 feat: hide plan settings in non-cloud mode, improve overrides UX (#110)
* feat: hide plan settings in non-cloud mode, improve plan overrides UX

1. Gate Plan column, Plan dropdown, and Plan Overrides behind cloud_mode
   so self-hosted instances don't see irrelevant billing UI.

2. Replace raw JSON textarea for plan overrides with a structured grid
   of labeled number inputs for each known limit (workspaces, items per
   workspace, members, API tokens, webhooks). Each field shows "default"
   as placeholder and accepts -1 for unlimited.

Addresses IDEA-561

* fix: address codex review — preserve extra overrides, fix colspan, validate integers

- Preserve non-modeled override keys (e.g. storage_bytes) by stashing
  them on select and merging them back on save
- Fix colspan: 6 columns in cloud mode, 5 without (was off by one)
- Use Number() + Number.isInteger() instead of parseInt to reject
  floats and scientific notation instead of silently truncating
2026-04-14 09:22:08 -04:00
xarmian ba01d95111 feat: add web UI for TOTP 2FA setup in user settings (#109)
* feat: add web UI for TOTP 2FA setup in user settings

Add a Two-Factor Authentication section to the console settings page
so users can enable/disable TOTP 2FA from the browser. The backend
API already existed (PR #77); this wires up the frontend.

- Add 2FA section to console settings with enable/disable flows
- Enable flow: QR code + manual secret + verification code input
- Recovery codes displayed with copy/download after setup
- Disable flow: password confirmation modal
- Add totp.setup/verify/disable methods to API client
- Add TOTP types (TOTPSetupResponse, TOTPVerifyResponse, etc.)
- Add totp_enabled to User type and /auth/me response
- Add qrcode npm dependency for rendering otpauth:// URIs

Closes TASK-402

* fix: address codex review — separate QR rendering from setup, use clipboard util

- Separate QR code rendering from TOTP setup API call so a QR failure
  doesn't abort setup when manual entry is still available
- Use existing copyToClipboard utility with legacy fallback instead of
  raw navigator.clipboard.writeText
2026-04-14 09:04:37 -04:00
xarmian 3d284641d7 feat: add audit log UI for admin console (#108)
* feat: add audit log UI for admin console

Replace placeholder with full audit log page: filterable by action type
and date range, paginated table with relative timestamps, color-coded
action badges, parsed metadata details, and IP addresses.

* fix: show Unknown for missing actors, guard against stale filter responses

Show "Unknown" instead of "System" for entries without actor info (e.g.
failed logins). Add a request counter so rapid filter changes discard
stale responses instead of overwriting newer results.
2026-04-14 07:05:49 -04:00
xarmian 56adba4b58 feat: add invitation management panel for admin console (#107)
* feat: add invitation management panel for admin console

Platform-wide view of all pending invitations with search, resend, and
revoke. Resend creates a fresh invitation code and sends the email.
New admin endpoints: GET/POST/DELETE for /admin/invitations.

* fix: check email opt-out on resend, abort on stale delete, reload list

Respect unsubscribe preferences before resending invitation emails.
Abort resend if the old invitation was already accepted/revoked
concurrently. Reload the full invitations list after resend since the
row ID changes.
2026-04-13 23:02:19 -04:00
xarmian 86451174ad feat: add user detail panel with workspace memberships (#106)
* feat: add user detail panel with workspace memberships

New GET /api/v1/admin/users/{id}/workspaces endpoint returning workspace
name, slug, role, and join date. Frontend loads memberships when a user
row is expanded and displays them as a linked list with role badges.

* fix: scope workspace fetch error/loading to active selection

Gate both the catch and finally blocks with a selectedId check so stale
requests from previously selected users don't wipe workspace data or
clear the loading indicator for the current selection.
2026-04-13 22:36:14 -04:00
xarmian b3af1acd07 feat: add last active tracking for users (#105)
* feat: add last active tracking for users

Track when users were last active via a throttled update (once per 5
minutes) in the auth middleware. Adds last_active_at column, displays
relative time in admin user list with full timestamp on hover.

* fix: bound last-active goroutine with 3s context timeout

Use a short-lived context for the background TouchUserActivity write
so it gets cancelled under DB pressure, preventing goroutine/connection
buildup from unbounded background work.
2026-04-13 22:19:41 -04:00
xarmian d968b551b7 feat: add account disable/deactivation (#104)
* feat: add account disable/deactivation for admin users

Allow admins to soft-disable user accounts without deleting data.
Disabled users get a 403 on all authenticated requests, their sessions
are invalidated on disable, and they show as visually dimmed with a
red "disabled" badge in the admin console. Includes migration for
disabled_at column, auth middleware check, disable/enable endpoints
with audit logging, and frontend toggle with confirmation dialog.

* refactor: auto-discover migrations from embedded filesystem

Replace hardcoded migration lists with fs.ReadDir on the embedded FS
directories. New migrations are now picked up automatically by filename
sort order — no need to manually register them in store.go.

* fix: block disabled users at login and capture IDs before async calls

Reject disabled accounts in the login handler before session creation,
not just in RequireAuth middleware (which exempts auth routes). Also
capture selectedId into a local const in all async admin panel functions
to prevent stale updates if the selection changes during a request.

* fix: enforce disabled check in OAuth and password reset flows, always invalidate sessions

Block disabled users in all session-minting paths (OAuth login, password
reset) not just password login. Also remove early return for
already-disabled users in the disable endpoint so session invalidation
always runs, handling retry after partial failure.
2026-04-13 21:56:40 -04:00
xarmian 79d7d26a00 feat: add admin password reset for other users (#103)
* feat: add admin password reset for other users

New POST /api/v1/admin/users/{id}/reset-password endpoint. When email is
configured, sends a password reset link. Otherwise generates a temporary
password and invalidates existing sessions. Includes audit logging via
new password_reset_by_admin action and frontend UI with confirmation.

* fix: treat session revocation and email send as hard failures

Make session invalidation failure abort the reset instead of silently
continuing, and send the reset email synchronously so delivery failures
are surfaced to the admin caller.
2026-04-13 20:59:35 -04:00
xarmian f97ab766f5 feat: add admin role management (promote/demote users) (#102)
* feat: add admin role management (promote/demote users)

Allow admins to change user roles between admin and member from the
admin console. Includes safety guards to prevent self-demotion and
demoting the last admin, with full audit logging.

* fix: make last-admin demotion guard atomic

Move the admin count check into the SQL UPDATE itself so two concurrent
demotion requests cannot both observe >1 admin and proceed. The
conditional UPDATE only demotes when at least one other admin exists,
eliminating the TOCTOU race.
2026-04-13 20:12:16 -04:00
xarmian a83da0e241 feat: refactor admin console into tabbed layout (#101)
* feat: refactor admin console into tabbed layout

Split the monolithic admin page into a tabbed layout with sub-pages:
- Users tab (default) — user list, search, plan editing
- Settings tab — email configuration and plan limits
- Invitations tab (placeholder for TASK-559)
- Audit Log tab (placeholder for TASK-560)

Extract shared admin utilities (adminFetch, adminPatch, adminPost,
types, reactive stats store) into lib/stores/admin.svelte.ts.

Part of PLAN-552: Admin Console Enhancement (IDEA-242)

* fix: show error state when admin users API fails

Instead of silently swallowing fetch errors and showing an empty table,
display an error message with a retry button. Addresses PR feedback.
2026-04-13 17:58:46 -04:00
xarmian f276745478 fix: sidebar collection counts ignore terminal status settings (#100)
When all items in a collection had terminal statuses (e.g. all bugs
"fixed"), the sidebar showed the total item count instead of 0.

Root cause: ActiveItemCount used `json:"omitempty"`, so a zero value
was omitted from the API response. The sidebar fallback logic then
displayed item_count (total) instead. Additionally, ListCollections
used a hardcoded global terminal status list instead of respecting
each collection's configured terminal_options.

- Remove omitempty from ItemCount/ActiveItemCount so 0 serializes
- Compute active counts per-collection using schema terminal_options
- Show count of 0 in sidebar when collection has items but all are done
2026-04-13 16:47:44 -04:00
xarmian 2e03222aa5 feat: restore pad init as smart multi-step entry point (#99)
* feat: restore `pad init` as smart multi-step entry point

Adds a top-level `pad init` command that detects the current state and
walks through each setup step as needed: configure connection, start
server, bootstrap admin account, authenticate, create/link workspace,
and install/update AI skill files.

Safe to re-run anytime — skips completed steps and shows a status
summary when everything is healthy.

Also refactors `pad workspace init` to use shared helpers and adds a
hint pointing users to `pad init` when prerequisites are missing.

Ref: IDEA-499, PLAN-546

* fix: validate --template flag before workspace creation in pad init

Adds the same preflight template validation that workspace init has,
preventing a partially initialized workspace from being created when
an invalid template name is passed.

Ref: PR #99 review feedback
2026-04-13 15:01:23 -04:00
xarmian ed87d0d676 Merge pull request #98 from xarmian/fix/db-commands-sqlite-support
fix: make pad db backup/restore work with SQLite
2026-04-13 10:39:23 -04:00
xarmian 40e6a8b705 fix: make pad db backup and pad db restore work with SQLite
The db commands were PostgreSQL-only, which made them useless for
self-hosted users on the default SQLite setup. Now both commands
auto-detect the database driver and do the right thing:

- SQLite (default): file copy of ~/.pad/pad.db with WAL/SHM handling
- PostgreSQL: existing pg_dump/psql behavior (unchanged)
2026-04-13 14:39:11 +00:00
xarmian 7ca0463e70 feat: browser-based CLI authentication flow (#97)
Replace the email/password terminal prompt in `pad auth login` with a
browser-based auth flow. The CLI creates a pending session, prints a URL
the user opens in their browser (works for localhost, remote VPS, or
Pad Cloud), and polls until the session is approved.

- Add CLI auth session endpoints (create, poll, approve)
- Add browser approval page at /auth/cli/{code}
- Rewrite `pad auth login` to use browser flow by default
- Keep `pad auth login --interactive` as email/password fallback
- Add login page redirect param support for post-login bounce-back
- Add SQLite and PostgreSQL migrations for cli_auth_sessions table

Closes PLAN-539, IDEA-404
2026-04-13 10:11:16 -04:00
xarmian 1ba9c91992 feat: email unsubscribe for non-transactional emails (#96)
* feat: email unsubscribe for non-transactional emails

Add CAN-SPAM compliant unsubscribe support:

- New email_optouts table (by email address, not user ID) so
  uninvited recipients can opt out without an account
- HMAC-signed unsubscribe tokens (derived from Maileroo API key)
  so links work without authentication
- GET /api/v1/unsubscribe endpoint with simple HTML confirmation page
- Invitation emails now include unsubscribe footer link
- Welcome emails accept unsubscribe URL parameter
- Before sending invitation emails, check opt-out table and silently
  skip opted-out addresses (prevents invite spam)
- Password reset emails are exempt (transactional, user-initiated)

Fixes BUG-256.

* fix: hide "Copy invite link" when code is unrecoverable

For hashed invitations the plaintext code can't be recovered, so the
button was copying a broken URL. Now shows "Sent via email" label
instead. Only shows the copy button when join_url or code is available.

Fixes BUG-255.
2026-04-13 09:14:04 -04:00
xarmian 26af891432 fix: dashboard "New Idea" button now targets first collection (#95)
The dashboard had two buttons that both called requestQuickAdd() with
no argument, so both created tasks. Now:

- "New Task" explicitly targets the tasks collection
- The second button dynamically targets the first non-system, non-task
  collection by sort order (showing its icon and name)
- requestQuickAdd() accepts an optional collection slug, which the
  sidebar respects when choosing the target collection

Fixes BUG-498.
2026-04-12 23:54:00 -04:00
xarmian ac24fb742c fix: breadcrumbs show parent item path for child items (#94)
When viewing a child item (e.g. TASK-101 under PLAN-10), the breadcrumb
now shows "Home / Plans / PLAN-10 / TASK-101" instead of the flat
"Home / Tasks / TASK-101".

- Add parent_slug and parent_collection_slug fields to Go Item model
- Populate them in both single-item and bulk enrichment paths
- Add corresponding TypeScript types
- Update breadcrumb nav to show parent collection and parent item
  when the item has a parent, falling back to the item's own collection

Fixes BUG-516.
2026-04-12 23:53:58 -04:00
xarmian e49a020fc5 fix: mobile UI — sidebar buttons, avatar, share copy (#93)
* fix: mobile UI bugs — sidebar buttons, avatar, share link copy

- Show sidebar + buttons on touch devices using @media (hover: none)
  instead of requiring hover (BUG-537)
- Add user avatar and menu to mobile TopBar header, filling the blank
  space next to the workspace selector (BUG-536)
- Wire ShareDialog copy into existing clipboard fallback utility so
  share link copy works over HTTP (BUG-513)

* fix(mobile): remove extra right padding on mobile topbar

The .topbar has 72px right padding to clear space for the absolutely-
positioned desktop avatar. On mobile the avatar is in the normal flex
flow, so that padding created a blank gap. Override to var(--space-3).
2026-04-12 23:41:35 -04:00