1
Fork 0
mirror of https://github.com/thegeneralist01/archivr synced 2026-10-09 12:55:00 +02:00

feat(collections): public collections, per-collection auth, UX improvements (#34)

* feat(core): add requires_auth to collections; include name in entry-collection memberships

- Add `requires_auth INTEGER NOT NULL DEFAULT 1` column to the
  collections DDL and as an idempotent ALTER TABLE migration in
  initialize_schema (archive DB), not initialize_auth_schema.
- CollectionRecord and CollectionSummary gain `requires_auth: bool`.
- create_collection() and update_collection() accept the new field.
- get_entry_collection_memberships() now returns collection name as the
  third tuple element; EntryCollectionMembership gains a `name` field
  so the sidebar can show human-readable names instead of raw UIDs.

* feat(server): conditional auth for public collections; add requires_auth + original_url to API

- CreateCollectionBody gains requires_auth (default true).
- PatchCollectionBody gains requires_auth: Option<bool>.
- get_collection_handler: load record first, then skip auth.require_auth()
  when record.requires_auth == false so public collections are accessible
  to unauthenticated callers; caller_bits falls back to ROLE_GUEST (1)
  so only visibility_bits=3 entries are returned to guests.
- Collection JSON response includes requires_auth and each entry now
  includes original_url for use by the public collection page.
- list_collections_handler keeps require_auth (management UI).

* feat(frontend): public collection page at /c/:archiveId/:collUid

- Detect PUBLIC_COLL_ROUTE at module load time (like PREVIEW_ROUTE) and
  return <PublicCollectionPage> before any auth checks so unauthenticated
  users can view public collections without hitting the login gate.
- PublicCollectionPage fetches via getCollection() and renders the
  server-filtered entry list (no client-side bitmask filtering - the
  server already applies caller_bits=GUEST for unauthenticated requests).
  Entry titles link to original_url when present; fall back to plain
  text when original_url is null.
- api.js createCollection() gains requiresAuth param (default true),
  sent as requires_auth in the request body.
- Storybook story covers WithEntries, Empty, and LoadError states.

* feat(frontend): collections view improvements

- addVis in the 'Add entry' form now syncs to the selected collection's
  default_visibility_bits via useEffect on collDetail, so the default
  matches the collection's configured entry visibility.
- Rename 'Default visibility' label to 'Entries\' default visibility'
  in both the detail pane and the create form to distinguish it from
  the new collection-level access setting.
- Add 'Require authentication to view' checkbox in the detail pane
  backed by a PATCH to requires_auth; reads collDetail?.requires_auth
  with fallback to the list-level selected record.
- Create form gains a matching requires_auth checkbox (default: true),
  passed as 5th arg to createCollection().

* feat(frontend): context rail collection improvements

- Show collection name (c.name) instead of raw UID in the sidebar
  Collections section; names now come from the updated
  EntryCollectionMembership API response.
- Fix horizontal overflow on long collection names: coll-name gains
  overflow:hidden + text-overflow:ellipsis + white-space:nowrap +
  min-width:0; coll-row gets overflow:hidden.
- Single-entry 'Add to collection' UI: dropdown + button inside the
  Collections rail section lets users add the current entry to any
  non-default collection without multi-selecting. After add, the
  membership list refreshes automatically.
- Collections section now shows even when entryCollections is empty,
  as long as non-default collections exist (so the add form is
  accessible for un-membered entries).
- Bulk 'Add to collection' now uses the target collection's
  default_visibility_bits instead of hardcoded 2 (Users only).
- Both bulk and single-entry dropdowns filter out slug='_default_'
  to match the backend rejection in add_entry_to_collection_handler.
- Collections list is now fetched on archiveId change (not just on
  bulk mode entry) so it is available for single-entry mode too.

* build(frontend): update static assets

* feat(frontend): public collection link UX + app-styled public page

CollectionsView:
- When a collection has requires_auth=false, show a read-only URL input
  and Copy button below the auth checkbox so the public link is
  immediately discoverable. The input auto-selects on focus so manual
  copy always works. Copy button tries navigator.clipboard.writeText
  first; falls back to execCommand('copy') for HTTP deployments where
  the Clipboard API is unavailable in non-secure contexts.

PublicCollectionPage:
- Rewritten to use the app's CSS classes and variables instead of
  bare inline styles, so it visually matches the main archive UI.
  Dark topbar (.pub-coll-topbar) with brand + collection name, paper
  background body, entry list via .coll-entries-list / .coll-entry-row /
  .coll-entry-info / .coll-entry-kind — the same classes used in the
  authenticated Collections view.

styles.css:
- .coll-public-link-row / -wrap / -input / .coll-copy-btn for the
  new link field in CollectionsView detail pane.
- .pub-coll-* classes for the public page layout and typography.

* feat(core): add get_collection_by_slug; scope search to active collection

- get_collection_by_slug(): new function mirroring get_collection_by_uid
  but matching on slug, used to resolve the _default_ collection when no
  ?collection param is supplied.
- SearchEntriesQuery gains collection_id: Option<i64>. When set, the
  search SQL adds an EXISTS subquery that checks collection_entries cef
  for both membership (cef.collection_id = ?) and visibility bits in
  that specific collection — preventing cross-collection visibility
  leaks where an entry is public in one collection but private in the
  current one. Without collection_id the original cross-collection
  visibility fallback is kept.

* feat(server): collection-scoped entries/search with uniform auth gate

All entry listing and search now route through the active collection:

list_entries (?collection=<uid>|main|<omitted>):
- Resolves the target collection; omitted or 'main' resolves to _default_.
- Checks requires_auth on that collection; gates auth conditionally.
- Returns list_entries_for_collection() — same EntrySummary shape.

search_entries_handler:
- Same collection resolution + conditional auth as list_entries.
- Sets search_query.collection_id so SQL scopes membership + visibility
  to the specific collection, not cross-collection fallback.

list_collections_handler:
- Dropped require_auth() — collection summaries (name/slug/uid/
  requires_auth/default_visibility_bits) are public metadata needed for
  the guest collection-switcher dropdown.

Tests:
- list_collections_requires_auth → list_collections_is_public (200).
- list_entries_requires_auth and search coverage still pass.

* feat(frontend): integrate collection switching into main Archive view

Replaces the standalone /c/:archiveId/:collUid public page with a
unified main-view approach where all collection logic lives at /.

URL param:
- ?collection=<uid> selects a collection; omitted or 'main' = default.
- 'main' is normalized to null in parseLocation() so the dropdown shows
  'All entries' and the URL stays clean.

Collection switcher (Topbar):
- Dropdown always visible (guests need it to navigate public collections).
- Non-default collections only (All entries = no param = _default_).
- Guest selecting an auth-required collection calls onSignInClick().
- handleCollectionChange checks both named and _default_ requires_auth
  before proceeding, redirecting guests to login if needed.

listCollections fetched for all users (guests too) since the endpoint
is now public; used to populate the switcher without auth.

Public-session mode (authenticated state, no currentUser):
- Auth gate: fetchArchives() + fetchEntries() with collection param;
  401 falls through to login, 200 proceeds as guest.
- auth:expired suppressed when !currentUser.
- fetchEntryDetail skipped; ContextRail shows entry summary + sign-in prompt.
- ContextRail selection effect skips tag/collection API calls.
- runs/tags not fetched in guest mode.
- Child row expansion disabled in EntryRow (hasChildren = false).

api.js:
- fetchEntries/searchEntries both thread ?collection=<uid> to server.

Deleted: PublicCollectionPage.jsx, PublicCollectionPage.stories.jsx,
copy-link UI from CollectionsView, pub-coll-*/copy-link CSS.

* build(frontend): update static assets

* feat(core): add is_entry_publicly_accessible; checks entry+parent vs public collections

* feat(server): allow guests to fetch detail/children/artifacts for public entries

* feat(frontend): guest collection dropdown filtering; public entry detail without auth wall

* build(frontend): update static assets

* test(server): public entry detail/artifact/children contract for guests

* feat(server): filter auth-required collections from guest list_collections response
This commit is contained in:
TheGeneralist 2026-07-24 20:16:17 +02:00 • committed by GitHub
parent 1af920eb63
commit e1ee05bd41
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 1218 additions and 185 deletions

View file

@ -163,6 +163,7 @@ pub struct CollectionRecord {
pub name: String,
pub slug: String,
pub default_visibility_bits: u32,
pub requires_auth: bool,
pub created_at: String,
}
@ -339,6 +340,7 @@ pub fn initialize_schema(conn: &Connection) -> Result<()> {
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
default_visibility_bits INTEGER NOT NULL DEFAULT 2,
requires_auth INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL
);
@ -434,6 +436,12 @@ pub fn initialize_schema(conn: &Connection) -> Result<()> {
// Migration: add notes_json column to existing capture_jobs tables.
// Silently ignored when the column already exists (idempotent).
let _ = conn.execute("ALTER TABLE capture_jobs ADD COLUMN notes_json TEXT", []);
// Migration: add requires_auth column to existing collections tables.
// Silently ignored when the column already exists (idempotent).
let _ = conn.execute(
"ALTER TABLE collections ADD COLUMN requires_auth INTEGER NOT NULL DEFAULT 1",
[],
);
Ok(())
}
@ -2349,6 +2357,7 @@ pub fn create_collection(
name: &str,
slug: &str,
default_visibility_bits: u32,
requires_auth: bool,
) -> Result<CollectionRecord> {
if slug.is_empty() || slug.starts_with('_') {
anyhow::bail!("collection slug must be non-empty and not start with underscore");
@ -2356,13 +2365,14 @@ pub fn create_collection(
let collection_uid = public_id("coll");
let now = now_timestamp();
conn.execute(
"INSERT INTO collections (collection_uid, name, slug, default_visibility_bits, created_at) \
VALUES (?1, ?2, ?3, ?4, ?5)",
"INSERT INTO collections (collection_uid, name, slug, default_visibility_bits, requires_auth, created_at) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
params![
collection_uid,
name,
slug,
default_visibility_bits as i64,
requires_auth as i64,
now
],
)?;
@ -2373,6 +2383,7 @@ pub fn create_collection(
name: name.to_string(),
slug: slug.to_string(),
default_visibility_bits,
requires_auth,
created_at: now,
})
}
@ -2380,7 +2391,7 @@ pub fn create_collection(
/// Lists all collections ordered by creation date.
pub fn list_collections(conn: &Connection) -> Result<Vec<CollectionRecord>> {
let mut stmt = conn.prepare(
"SELECT id, collection_uid, name, slug, default_visibility_bits, created_at \
"SELECT id, collection_uid, name, slug, default_visibility_bits, created_at, requires_auth \
FROM collections ORDER BY created_at ASC",
)?;
stmt.query_map([], |row| {
@ -2391,6 +2402,7 @@ pub fn list_collections(conn: &Connection) -> Result<Vec<CollectionRecord>> {
slug: row.get(3)?,
default_visibility_bits: row.get::<_, i64>(4)? as u32,
created_at: row.get(5)?,
requires_auth: row.get::<_, i64>(6)? != 0,
})
})?
.collect::<Result<_, _>>()
@ -2400,7 +2412,7 @@ pub fn list_collections(conn: &Connection) -> Result<Vec<CollectionRecord>> {
/// Returns a collection by its uid, or None if not found.
pub fn get_collection_by_uid(conn: &Connection, uid: &str) -> Result<Option<CollectionRecord>> {
conn.query_row(
"SELECT id, collection_uid, name, slug, default_visibility_bits, created_at \
"SELECT id, collection_uid, name, slug, default_visibility_bits, created_at, requires_auth \
FROM collections WHERE collection_uid = ?1",
[uid],
|row| {
@ -2411,6 +2423,7 @@ pub fn get_collection_by_uid(conn: &Connection, uid: &str) -> Result<Option<Coll
slug: row.get(3)?,
default_visibility_bits: row.get::<_, i64>(4)? as u32,
created_at: row.get(5)?,
requires_auth: row.get::<_, i64>(6)? != 0,
})
},
)
@ -2418,6 +2431,24 @@ pub fn get_collection_by_uid(conn: &Connection, uid: &str) -> Result<Option<Coll
.map_err(Into::into)
}
/// Returns a collection by its slug, or None if not found.
pub fn get_collection_by_slug(conn: &Connection, slug: &str) -> Result<Option<CollectionRecord>> {
conn.query_row(
"SELECT id, collection_uid, name, slug, default_visibility_bits, created_at, requires_auth \
FROM collections WHERE slug = ?1",
[slug],
|row| Ok(CollectionRecord {
id: row.get(0)?,
collection_uid: row.get(1)?,
name: row.get(2)?,
slug: row.get(3)?,
default_visibility_bits: row.get::<_, i64>(4)? as u32,
created_at: row.get(5)?,
requires_auth: row.get::<_, i64>(6)? != 0,
}),
).optional().map_err(Into::into)
}
/// Adds an entry to a collection with given visibility_bits. Idempotent (INSERT OR IGNORE).
pub fn add_entry_to_collection(
conn: &Connection,
@ -2462,24 +2493,55 @@ pub fn remove_entry_from_collection(
Ok(n > 0)
}
/// Returns (collection_id, collection_uid, visibility_bits) for all collections containing an entry.
/// Returns (collection_id, collection_uid, name, visibility_bits) for all collections containing an entry.
pub fn get_entry_collection_memberships(
conn: &Connection,
entry_id: i64,
) -> Result<Vec<(i64, String, u32)>> {
) -> Result<Vec<(i64, String, String, u32)>> {
let mut stmt = conn.prepare(
"SELECT ce.collection_id, c.collection_uid, ce.visibility_bits \
"SELECT ce.collection_id, c.collection_uid, c.name, ce.visibility_bits \
FROM collection_entries ce \
JOIN collections c ON c.id = ce.collection_id \
WHERE ce.entry_id = ?1",
)?;
stmt.query_map([entry_id], |row| {
Ok((row.get(0)?, row.get(1)?, row.get::<_, i64>(2)? as u32))
Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get::<_, i64>(3)? as u32))
})?
.collect::<Result<_, _>>()
.map_err(Into::into)
}
/// Returns true if this entry (or its direct parent, for child entries) is in at least one
/// collection with `requires_auth = false` AND `collection_entries.visibility_bits & ROLE_GUEST (1) != 0`.
///
/// Child entries are not directly assigned to collections; they inherit visibility from their
/// parent's collection membership, matching the logic in `list_child_entries`.
pub fn is_entry_publicly_accessible(conn: &Connection, entry_uid: &str) -> Result<bool> {
let count: i64 = conn.query_row(
"SELECT COUNT(*) FROM archived_entries e \
WHERE e.entry_uid = ?1 \
AND (\
EXISTS (\
SELECT 1 FROM collection_entries ce \
JOIN collections c ON c.id = ce.collection_id \
WHERE ce.entry_id = e.id \
AND c.requires_auth = 0 \
AND (ce.visibility_bits & 1) != 0\
) \
OR (e.parent_entry_id IS NOT NULL AND EXISTS (\
SELECT 1 FROM collection_entries ce_p \
JOIN collections c ON c.id = ce_p.collection_id \
WHERE ce_p.entry_id = e.parent_entry_id \
AND c.requires_auth = 0 \
AND (ce_p.visibility_bits & 1) != 0\
))\
)",
[entry_uid],
|row| row.get(0),
)?;
Ok(count > 0)
}
/// Renames a collection and/or updates its default_visibility_bits.
/// Returns true if updated, false if not found.
/// Refuses to rename the '_default_' collection but allows changing its
@ -2489,6 +2551,7 @@ pub fn update_collection(
collection_uid: &str,
new_name: Option<&str>,
new_visibility_bits: Option<u32>,
requires_auth: Option<bool>,
) -> Result<bool> {
let coll = get_collection_by_uid(conn, collection_uid)?;
let Some(coll) = coll else { return Ok(false) };
@ -2497,9 +2560,10 @@ pub fn update_collection(
}
let name = new_name.unwrap_or(&coll.name);
let vbits = new_visibility_bits.unwrap_or(coll.default_visibility_bits);
let auth = requires_auth.unwrap_or(coll.requires_auth);
conn.execute(
"UPDATE collections SET name = ?1, default_visibility_bits = ?2 WHERE id = ?3",
params![name, vbits as i64, coll.id],
"UPDATE collections SET name = ?1, default_visibility_bits = ?2, requires_auth = ?3 WHERE id = ?4",
params![name, vbits as i64, auth as i64, coll.id],
)?;
Ok(true)
}
@ -2962,7 +3026,7 @@ mod tests {
};
// Changing default_visibility_bits on _default_ must succeed.
let updated = update_collection(&conn, &coll_uid, None, Some(3)).unwrap();
let updated = update_collection(&conn, &coll_uid, None, Some(3), None).unwrap();
assert!(updated, "visibility change on _default_ should succeed");
let bits: u32 = conn
.query_row(
@ -2974,7 +3038,7 @@ mod tests {
assert_eq!(bits, 3, "default_visibility_bits should be updated to 3");
// Renaming _default_ must still be rejected.
let err = update_collection(&conn, &coll_uid, Some("My Archive"), None);
let err = update_collection(&conn, &coll_uid, Some("My Archive"), None, None);
assert!(err.is_err(), "renaming _default_ must be rejected");
assert!(
err.unwrap_err().to_string().contains("cannot rename"),