Skip to content

feat: address all remaining TODOs#9

Merged
EinfachMxrc merged 3 commits into
mainfrom
feat/remaining-todos
Apr 6, 2026
Merged

feat: address all remaining TODOs#9
EinfachMxrc merged 3 commits into
mainfrom
feat/remaining-todos

Conversation

@EinfachMxrc

@EinfachMxrc EinfachMxrc commented Apr 6, 2026

Copy link
Copy Markdown
Owner

Summary

Alle verbleibenden TODOs aus der README-Liste adressiert.

Changes

Auth: SHA-256 (Security)

  • simpleHash (32-bit, unsicher) ersetzt durch sha256Hash via crypto.subtle
  • Login migriert Legacy-Hashes (mvp_…) automatisch zu SHA-256 beim nächsten Login
  • Keine manuellen Schritte nötig – backwards-kompatibel

Zuschauer-Count (Echtzeit)

  • Neues Schema: viewerHeartbeats-Tabelle
  • Öffentliche Handout-Seite sendet alle 30s einen Heartbeat
  • Session-Seite zeigt aktive Zuschauer neben dem Status-Badge
  • Stündlicher Cron löscht alte Heartbeats (convex/crons.ts)

Mehrere aktive Sessions

  • Dashboard-Warnung wenn >1 Live-Session für dasselbe Handout läuft

Export / Drucken

  • Neue Seite /dashboard/handout/[id]/print: alle Blöcke mit Reveal-Badges
  • Öffnet automatisch den Print-Dialog (Print-to-PDF funktioniert damit)
  • Button „Exportieren" im Handout-Editor-Header

PowerPoint Fullscreen-Workaround

  • Direkte Foliennummer-Eingabe im Add-in (immer sichtbar wenn verbunden)
  • Schnelle Korrektur wenn Auto-Sync im Vollbild-Modus suspendiert wird

Test plan

  • Login mit bestehendem Demo-Account → Hash wird zu sha256_ migriert
  • Neues Konto registrieren → direkt sha256_-Hash
  • Öffentliche Handout-Seite öffnen → Viewer-Count in Session-Seite steigt
  • Mehrere Live-Sessions erstellen → Warnung im Dashboard
  • „Exportieren" → Druckseite öffnet mit allen Blöcken
  • Add-in: Foliennummer eingeben → Session wird auf diese Folie gesetzt

🤖 Generated with Claude Code

Summary by CodeRabbit

Release Notes

  • New Features
    • Manual slide number input added to PowerPoint add-in for direct navigation
    • Print-friendly preview page for handouts with blocks and reveal badges
    • Real-time viewer count display in active session pages
    • Warning banner when multiple presentations are live for the same handout
    • Enhanced authentication system with automatic legacy hash migration

Auth:
- Replace 32-bit simpleHash with SHA-256 via Web Crypto API (crypto.subtle)
- Auto-upgrade legacy mvp_ hashes to sha256_ transparently on next login
- New registrations and demo seed use SHA-256 from the start

Viewer count:
- New viewerHeartbeats schema table with compound index
- pingViewer public mutation: upsert heartbeat every 30s from public page
- getViewerCount presenter query: count heartbeats within 90s window
- Session page shows live viewer count next to status badge
- Hourly cron cleans up stale heartbeats (convex/crons.ts)

Multiple sessions:
- Dashboard warns when multiple live sessions run for the same handout

Export:
- New /dashboard/handout/[id]/print page: all blocks with reveal badges
- Auto-opens print dialog on load; print CSS hides UI chrome
- "Exportieren" button added to handout editor header

PowerPoint fullscreen workaround:
- Direct slide number input in the add-in (always visible when connected)
- Allows quick recovery when auto-sync is suspended in fullscreen mode

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@netlify

netlify Bot commented Apr 6, 2026

Copy link
Copy Markdown

Deploy Preview for handoutmarc failed.

Name Link
🔨 Latest commit 8584e26
🔍 Latest deploy log https://app.netlify.com/projects/handoutmarc/deploys/69d3c07879fd000008f53029

@coderabbitai

coderabbitai Bot commented Apr 6, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

The changes implement four previously pending features: PowerPoint fullscreen workaround via direct slide-number input, migration to SHA-256 authentication with automatic legacy hash migration, real-time viewer count tracking with 30-second heartbeat polling, and a dedicated print route. Additionally, the dashboard now warns about multiple concurrent live sessions for the same handout.

Changes

Cohort / File(s) Summary
Documentation & Project Status
README.md
Updated "Bekannte Grenzen / TODOs" section to document four completed features: PowerPoint slide input, SHA-256 auth with migration, multiple session warnings, viewer counts, and print export route.
PowerPoint Add-in
apps/powerpoint-addin/src/App.tsx
Added new "Folie direkt eingeben" UI section with number input and submit button to allow manual direct slide-number navigation, integrating with existing syncSlide flow.
Dashboard UI Enhancements
apps/web/src/app/dashboard/page.tsx, apps/web/src/app/dashboard/handout/[id]/page.tsx, apps/web/src/app/dashboard/session/[id]/page.tsx
Added yellow warning banner for multiple concurrent live sessions per handout, "Drucken" button linking to new print route, and live viewer count badge showing real-time viewer count from getViewerCount query.
Print Export Route
apps/web/src/app/dashboard/handout/[id]/print/page.tsx
New print-friendly page that fetches handout with blocks, sorts by order, renders block titles with reveal-rule badges, Markdown content, and auto-triggers window.print() after 300ms with print CSS to hide toolbar and prevent block splitting.
Public Viewer Presence Tracking
apps/web/src/app/h/[token]/page.tsx
Added pingViewer mutation sending viewer heartbeat every 30 seconds using stable per-session viewerId stored in sessionStorage; errors suppressed silently.
Authentication & Password Security
convex/_utils.ts, convex/auth.ts
Introduced sha256Hash utility using Web Crypto API with slide-handout salt; switched registration to SHA-256; enhanced login to accept both SHA-256 and legacy hashes, transparently migrating legacy hashes on successful login.
Viewer Presence Backend
convex/viewers.ts, convex/schema.ts
Added viewers.ts module with pingViewer mutation (upserts heartbeats), getViewerCount query (counts active viewers within 90s window), and cleanupHeartbeats internal mutation; added viewerHeartbeats table with indexes on sessionId and (sessionId, viewerId).
Scheduled Maintenance & Demo Init
convex/crons.ts, convex/init.ts
Added new cron module scheduling hourly cleanupHeartbeats execution; updated demo seeding to use async sha256Hash instead of legacy simpleHash.

Sequence Diagram(s)

sequenceDiagram
    participant Client as Public Viewer<br/>(h/[token])
    participant Server as Convex Server<br/>(viewers.ts)
    participant DB as ViewerHeartbeats<br/>Table

    Client->>Client: Every 30s: getViewerId()
    Client->>Server: pingViewer(publicToken, viewerId)
    Server->>Server: Find active session by publicToken
    Server->>DB: Upsert heartbeat<br/>(sessionId, viewerId, now)
    DB-->>Server: Success

    Presenter->>Server: getViewerCount(token, sessionId)
    Server->>DB: Query heartbeats for sessionId<br/>where lastSeenAt > now - 90s
    DB-->>Server: Count of active viewers
    Server-->>Presenter: Display viewer badge

    Note over Server,DB: Hourly scheduled cron
    Server->>DB: cleanupHeartbeats()<br/>Delete rows older than 24h
    DB-->>Server: { deleted: N }
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~30 minutes

Possibly related PRs

  • PR #1: Directly extends and modifies the same core modules (apps/powerpoint-addin/src/App.tsx, convex/_utils.ts, convex/auth.ts) introducing the foundational architecture for direct-slide input, SHA-256 hashing, and authentication migration that this PR builds upon.
  • PR #8: Modifies convex/init.ts demo seeding logic; this PR updates the same file to adopt async sha256Hash, reflecting a shared concern with initialization and credential handling.

Poem

🐰 Hark! A rabbit hops with glee,
Direct slides now, one-two-three!
Viewer hearts beat thirty strong,
SHA-256 rights the auth song.
Print with grace, no screens to fight—
Our handout slides, now pixel-bright! 📄✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 45.45% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and accurately summarizes the main objective of the changeset: addressing all remaining TODO items documented in the README.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/remaining-todos

Comment @coderabbitai help to get the list of available commands and usage tips.

@greptile-apps

greptile-apps Bot commented Apr 6, 2026

Copy link
Copy Markdown

Greptile Summary

This PR addresses all remaining TODOs by upgrading password hashing to SHA-256, adding real-time viewer counting via heartbeats, adding a multi-live-session warning, adding a print/export page for handouts, and adding a direct slide-number input in the PowerPoint add-in for fullscreen recovery.

Key changes:

  • convex/_utils.ts: Replaces the weak 32-bit simpleHash with sha256Hash (Web Crypto API); legacy hashes auto-upgraded on next login
  • convex/schema.ts + convex/viewers.ts: New viewerHeartbeats table with pingViewer / getViewerCount / cleanupHeartbeats mutations
  • convex/crons.ts: Hourly cron triggers cleanupHeartbeats
  • apps/web/src/app/h/[token]/page.tsx: Public handout page sends a 30 s heartbeat
  • apps/web/src/app/dashboard/session/[id]/page.tsx: Session page shows live viewer count
  • apps/web/src/app/dashboard/page.tsx: Warns when >1 live session exists for the same handout
  • apps/web/src/app/dashboard/handout/[id]/print/page.tsx: New print/export page that auto-opens the browser print dialog
  • apps/powerpoint-addin/src/App.tsx: Direct slide-number input for fullscreen recovery

Issues found:

  • The SHA-256 upgrade uses a static, application-wide salt — all users with the same password produce identical hashes, eliminating the primary defence against precomputed dictionary attacks.
  • cleanupHeartbeats performs a full-table scan (.collect()) with no server-side timestamp filter, which will fail at scale when the table grows beyond Convex's per-query document limit.
  • The useEffect dependency in the print page uses [!!data] (a derived boolean) instead of [data], which is an unusual pattern that ESLint's exhaustive-deps rule would flag.

Confidence Score: 4/5

Safe to merge with the static-salt security concern tracked as a follow-up; no changes break existing functionality.

All five features work correctly end-to-end. The static-salt issue in sha256Hash is a meaningful security regression over a proper bcrypt/argon2 approach, but is still a significant improvement over the legacy 32-bit hash and the app is explicitly MVP-scoped. The cleanup full-table-scan is a real scalability risk but only triggers in a background cron and won't affect users until the table is very large. The useEffect dependency is a cosmetic issue. No data-loss or runtime errors are introduced.

convex/_utils.ts (static salt), convex/viewers.ts (full table scan in cleanupHeartbeats)

Important Files Changed

Filename Overview
convex/_utils.ts Replaces simpleHash with sha256Hash (Web Crypto); migration path is sound but the static application-wide salt removes the primary defence against precomputed attacks.
convex/auth.ts Login transparently upgrades legacy mvp_ hashes to SHA-256 on first sign-in; logic is correct and backwards-compatible.
convex/viewers.ts Adds viewer heartbeat ping, count query, and hourly cleanup; cleanup uses a full table scan that will hit Convex's document limit at scale.
convex/schema.ts Adds viewerHeartbeats table with correct indexes for session and session+viewer lookups.
convex/crons.ts Registers a clean hourly cron for cleanupHeartbeats; straightforward and correct.
convex/init.ts Demo seeding updated to use sha256Hash; idempotent and correct.
apps/web/src/app/h/[token]/page.tsx Public handout page sends a 30 s heartbeat; effect cleanup and sessionStorage-based viewerId are well-implemented.
apps/web/src/app/dashboard/session/[id]/page.tsx Session page shows authenticated viewer count alongside the status badge; query is correctly skipped until both token and session data are available.
apps/web/src/app/dashboard/page.tsx Dashboard warns when multiple live sessions exist for the same handout; detection logic is correct.
apps/web/src/app/dashboard/handout/[id]/print/page.tsx Print/export page auto-triggers the browser print dialog; works correctly but uses unusual [!!data] dependency in useEffect.
apps/web/src/app/dashboard/handout/[id]/page.tsx Adds an Export button linking to the new print page; no logic changes.
apps/powerpoint-addin/src/App.tsx Adds direct slide-number input form for fullscreen recovery; form validation (NaN guard, min 1) is correct.

Sequence Diagram

sequenceDiagram
    participant Viewer as Viewer Browser
    participant Public as /h/[token]
    participant Convex as Convex DB
    participant Session as Session Page
    participant Cron as Hourly Cron

    Viewer->>Public: Open handout page
    Public->>Convex: pingViewer({publicToken, viewerId})
    Convex->>Convex: Upsert viewerHeartbeats row
    loop Every 30s
        Public->>Convex: pingViewer({publicToken, viewerId})
        Convex->>Convex: Update lastSeenAt
    end

    Session->>Convex: getViewerCount({token, sessionId})
    Convex->>Convex: Filter heartbeats where lastSeenAt > now-90s
    Convex-->>Session: viewerCount

    Cron->>Convex: cleanupHeartbeats()
    Convex->>Convex: Delete rows where lastSeenAt < now-24h
Loading

Fix All in Codex

Reviews (1): Last reviewed commit: "feat: address all remaining TODOs" | Re-trigger Greptile

Comment thread convex/_utils.ts
Comment on lines +26 to +33
export async function sha256Hash(password: string): Promise<string> {
const salted = `slide-handout:${password}`;
const data = new TextEncoder().encode(salted);
const buffer = await crypto.subtle.digest("SHA-256", data);
const hex = Array.from(new Uint8Array(buffer))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
return `sha256_${hex}`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Static application-wide salt weakens SHA-256 password security

sha256Hash prepends the fixed string "slide-handout:" to every password before hashing. Because this prefix is constant and publicly visible in source code, all users who share the same password produce identical hashes. An attacker who obtains the database can build a single rainbow table targeting this known prefix and crack every matching account in one pass.

A secure implementation requires a random per-user salt stored alongside the hash. At minimum, the schema would need a passwordSalt field and the hash/verify logic updated:

// Generate on register/upgrade:
const saltBytes = crypto.getRandomValues(new Uint8Array(16));
const salt = Array.from(saltBytes).map(b => b.toString(16).padStart(2, '0')).join('');
const hash = await sha256Hash(password, salt);
// Store both `hash` and `salt` in the presenters row.

export async function sha256Hash(password: string, salt: string): Promise<string> {
  const data = new TextEncoder().encode(`slide-handout:${salt}:${password}`);
  const buffer = await crypto.subtle.digest('SHA-256', data);
  const hex = Array.from(new Uint8Array(buffer)).map(b => b.toString(16).padStart(2, '0')).join('');
  return `sha256_${hex}`;
}

Alternatively, crypto.subtle.importKey + crypto.subtle.deriveBits (PBKDF2) is available in Convex's V8 isolate and provides iteration-count hardening on top of salting.

Fix in Codex

Comment thread convex/viewers.ts
Comment on lines +85 to +99
export const cleanupHeartbeats = internalMutation({
args: {},
handler: async (ctx) => {
const cutoff = Date.now() - 24 * 60 * 60_000; // älter als 24h
const old = await ctx.db.query("viewerHeartbeats").collect();
let deleted = 0;
for (const h of old) {
if (h.lastSeenAt < cutoff) {
await ctx.db.delete(h._id);
deleted++;
}
}
return { deleted };
},
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Full table scan in cleanupHeartbeats will not scale

cleanupHeartbeats calls .collect() on the entire viewerHeartbeats table and then filters by lastSeenAt in JavaScript. Convex imposes a per-query document limit (currently 16 384 documents), so this cron job will throw once the table grows large.

The fix is to add a by_lastSeenAt index to the schema and filter at the database level:

// In schema.ts, add to viewerHeartbeats:
.index("by_lastSeenAt", ["lastSeenAt"])

// In cleanupHeartbeats:
const old = await ctx.db
  .query("viewerHeartbeats")
  .withIndex("by_lastSeenAt", (q) => q.lt("lastSeenAt", cutoff))
  .collect();

This ensures only expired rows are fetched, regardless of total table size.

Fix in Codex

Comment on lines +24 to +29
useEffect(() => {
if (data) {
const timer = setTimeout(() => window.print(), 300);
return () => clearTimeout(timer);
}
}, [!!data]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Unusual [!!data] dependency in useEffect

The dependency array [!!data] passes a derived boolean expression rather than the reactive value data itself. ESLint's react-hooks/exhaustive-deps rule flags derived values in dependency arrays because React's equality check operates on the coerced type, and the indirection obscures what the effect truly depends on.

The idiomatic pattern that achieves the same intent and satisfies the linter:

Suggested change
useEffect(() => {
if (data) {
const timer = setTimeout(() => window.print(), 300);
return () => clearTimeout(timer);
}
}, [!!data]);
}, [data]);

The if (data) guard inside the callback already prevents the print dialog from opening when data is still undefined, so the behaviour is identical.

Fix in Codex

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

🧹 Nitpick comments (2)
convex/_utils.ts (1)

22-34: Improvement over legacy hash; consider per-user salt and key stretching for production.

The SHA-256 implementation is a significant security improvement over the 32-bit simpleHash. However, for production hardening, consider:

  1. Per-user salt: The static salt "slide-handout:" means identical passwords produce identical hashes. A unique random salt per user (stored alongside the hash) would prevent rainbow table attacks.

  2. Key stretching: SHA-256 is fast by design, making it vulnerable to brute-force. Libraries like bcrypt or argon2 are preferred for password hashing as they're intentionally slow.

For an MVP with the migration path in place, this is acceptable—but flag for future hardening before production scale.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@convex/_utils.ts` around lines 22 - 34, The sha256Hash function currently
uses a static salt ("slide-handout:") and no key stretching; change it to accept
and use a per-user salt and prepare for a slow KDF: update sha256Hash(password:
string, salt?: string) to require/provision a unique random salt (store
alongside the hash) and derive the stored value as e.g.
"sha256_<salt_hex>_<hash_hex>" or migrate to a proper KDF by replacing
sha256Hash with a wrapper that delegates to a slow algorithm (bcrypt/argon2)
when available; ensure code paths referencing sha256Hash are updated to
supply/handle the salt and that salt generation (secure random) and storage
schema are implemented where accounts are created.
convex/viewers.ts (1)

72-78: Add time-based indexes before this becomes a hot path.

Lines 73-78 materialize every heartbeat for the session and filter in memory, while Lines 89-96 scan the whole table hourly for cleanup. That makes both paths grow with historical traffic instead of the 90-second active window. A lastSeenAt index (or sessionId,lastSeenAt) would let both operations range-scan only recent/stale rows.

Also applies to: 88-96

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@convex/viewers.ts` around lines 72 - 78, The viewerHeartbeats query currently
materializes all rows for a session and filters by lastSeenAt in memory, and the
hourly cleanup also scans the whole table; add a time-based index (e.g.,
"by_session_lastSeen" or "by_lastSeen") on lastSeenAt or on
(sessionId,lastSeenAt), then change the active-count query using
viewerHeartbeats.withIndex("by_session_lastSeen", q => q.eq("sessionId",
args.sessionId).gt("lastSeenAt", cutoff)) (or the equivalent range predicate) so
only recent rows are scanned, and update the cleanup routine to range-scan stale
rows via the new index instead of iterating the whole table.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/web/src/app/dashboard/handout/`[id]/print/page.tsx:
- Around line 94-96: The reveal badge is being hidden in printed/PDF output
because the span rendering revealLabel(block.revealRule) includes the "no-print"
class; remove that class (or adjust print CSS to override .no-print to display
inline in `@media` print) so the revealLabel is included in exports. Locate the
span that renders revealLabel(block.revealRule) in page.tsx, remove "no-print"
from its className (or add a print-specific rule that forces .no-print to be
visible) and verify printed/PDF output shows the badge.
- Around line 18-21: The current useQuery call with
api.handouts.getHandoutWithBlocks passes "skip" when token is absent so data
never resolves and the page stays in the loading branch; change the logic to
explicitly handle the skipped-query path: first check the auth store hydration
flag from useAuthStore (e.g., isHydrated or similar) and if not hydrated show
nothing/placeholder, then if hydrated and token is missing render an
auth-required state or redirect instead of the loading spinner, and only call
useQuery(api.handouts.getHandoutWithBlocks, { token, handoutId }) when token is
present; update the component branches that render Lädt... (the loading lines)
to distinguish between real loading (data.status/loading) and the
skipped/no-token case so you don't show a permanent spinner.

In `@apps/web/src/app/h/`[token]/page.tsx:
- Around line 29-38: The heartbeat effect starts while sessionInfo is undefined
(loading) and never reruns if the query resolves to null, so stop/avoid starting
pings until sessionInfo is resolved and ensure the effect reacts to a transition
from loading→null; modify the useEffect guarding logic (the effect around
getViewerId/pingViewer/ping) to only start the interval when sessionInfo !==
undefined && sessionInfo !== null && sessionInfo.status !== "ended", and include
sessionInfo (not just sessionInfo?.status) in the dependency array so the effect
will re-run and clear the interval when the query resolves to null; reference
the useEffect, sessionInfo, publicToken, getViewerId, and pingViewer identifiers
when making the change.

In `@convex/auth.ts`:
- Around line 30-33: Replace the use of the fast sha256Hash for storing
passwords with a proper password KDF (e.g., bcrypt or Argon2) when creating
presenter records and in the legacy-upgrade path: stop calling sha256Hash to
produce passwordHash in the registration flow (the assignment that goes into
ctx.db.insert("presenters")), instead generate a per-user salted KDF hash with a
secure cost/work factor and store that; likewise locate the legacy upgrade logic
that checks existing SHA-256 hashes (the branch handling oldPassword / legacy
verification) and update it to verify the old SHA-256 value, then re-hash the
password with the chosen KDF and replace the stored passwordHash. Also ensure
any authentication/verify code uses the KDF's verify function rather than raw
SHA-256 comparisons and preserve a migration flag or scheme identifier so you
can recognize legacy hashes.

In `@convex/viewers.ts`:
- Around line 30-57: The handler currently writes the raw args.viewerId into the
indexed viewerHeartbeats table (functions: handler, viewerHeartbeats
query/insert/patch), which allows unbounded/high-cardinality values; before any
DB read/write, validate and bound viewerId (e.g., enforce allowed charset and
max length) or replace it with a fixed-size fingerprint (hash) and use that
bounded identifier in all queries/patches/inserts so the index only ever stores
a constrained, predictable value; ensure the same transformation is used for the
existing lookup (withIndex("by_session_viewer")) and for insert/patch
operations.
- Around line 64-79: getViewerCount currently calls requirePresenter(ctx,
args.token) but never verifies that args.sessionId belongs to that presenter;
fix by loading the presentation session and checking ownership before returning
the count: after requirePresenter, fetch the session via
ctx.db.get(args.sessionId) (presentationSessions), ensure the session exists and
its presenter identifier (e.g., session.presenterId or session.presenterToken)
matches the authenticated presenter identity returned by requirePresenter (or
modify requirePresenter to return the presenter id), and throw an unauthorized
error if it does not; only then query viewerHeartbeats and return the active
count.

In `@README.md`:
- Line 244: The README contains a mismatched-quote string `„Add-in
verbinden"-Panel`; replace the ASCII closing quote with the matching German
closing quote so the text reads `„Add-in verbinden“-Panel` (or remove the quotes
entirely) to ensure consistent quotation marks and correct rendering.

---

Nitpick comments:
In `@convex/_utils.ts`:
- Around line 22-34: The sha256Hash function currently uses a static salt
("slide-handout:") and no key stretching; change it to accept and use a per-user
salt and prepare for a slow KDF: update sha256Hash(password: string, salt?:
string) to require/provision a unique random salt (store alongside the hash) and
derive the stored value as e.g. "sha256_<salt_hex>_<hash_hex>" or migrate to a
proper KDF by replacing sha256Hash with a wrapper that delegates to a slow
algorithm (bcrypt/argon2) when available; ensure code paths referencing
sha256Hash are updated to supply/handle the salt and that salt generation
(secure random) and storage schema are implemented where accounts are created.

In `@convex/viewers.ts`:
- Around line 72-78: The viewerHeartbeats query currently materializes all rows
for a session and filters by lastSeenAt in memory, and the hourly cleanup also
scans the whole table; add a time-based index (e.g., "by_session_lastSeen" or
"by_lastSeen") on lastSeenAt or on (sessionId,lastSeenAt), then change the
active-count query using viewerHeartbeats.withIndex("by_session_lastSeen", q =>
q.eq("sessionId", args.sessionId).gt("lastSeenAt", cutoff)) (or the equivalent
range predicate) so only recent rows are scanned, and update the cleanup routine
to range-scan stale rows via the new index instead of iterating the whole table.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 3e9959e9-16cb-4365-ab37-a6df4f6f3915

📥 Commits

Reviewing files that changed from the base of the PR and between decce1b and 432aa51.

📒 Files selected for processing (13)
  • README.md
  • apps/powerpoint-addin/src/App.tsx
  • apps/web/src/app/dashboard/handout/[id]/page.tsx
  • apps/web/src/app/dashboard/handout/[id]/print/page.tsx
  • apps/web/src/app/dashboard/page.tsx
  • apps/web/src/app/dashboard/session/[id]/page.tsx
  • apps/web/src/app/h/[token]/page.tsx
  • convex/_utils.ts
  • convex/auth.ts
  • convex/crons.ts
  • convex/init.ts
  • convex/schema.ts
  • convex/viewers.ts

Comment on lines +18 to +21
const data = useQuery(
api.handouts.getHandoutWithBlocks,
token ? { token, handoutId: handoutId as Id<"handouts"> } : "skip"
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Handle the skipped-query path separately from the loading state.

Lines 18-21 pass "skip" when token is missing. In that state data never resolves, so Lines 40-41 keep rendering Lädt... forever instead of redirecting or showing an auth-required state. If useAuthStore() rehydrates asynchronously, gate this on its hydration flag so you don't trade the permanent spinner for a login flash.

Also applies to: 40-41

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/app/dashboard/handout/`[id]/print/page.tsx around lines 18 - 21,
The current useQuery call with api.handouts.getHandoutWithBlocks passes "skip"
when token is absent so data never resolves and the page stays in the loading
branch; change the logic to explicitly handle the skipped-query path: first
check the auth store hydration flag from useAuthStore (e.g., isHydrated or
similar) and if not hydrated show nothing/placeholder, then if hydrated and
token is missing render an auth-required state or redirect instead of the
loading spinner, and only call useQuery(api.handouts.getHandoutWithBlocks, {
token, handoutId }) when token is present; update the component branches that
render Lädt... (the loading lines) to distinguish between real loading
(data.status/loading) and the skipped/no-token case so you don't show a
permanent spinner.

Comment on lines +94 to +96
<span className="no-print text-xs bg-blue-50 text-blue-700 border border-blue-200 rounded px-1.5 py-0.5">
{revealLabel(block.revealRule)}
</span>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Reveal badges are excluded from the actual print output.

Line 94 adds no-print, and Lines 49-50 hide that class in print media. That means the preview shows the reveal label, but the exported PDF / printed page drops it entirely.

Suggested fix
-                <span className="no-print text-xs bg-blue-50 text-blue-700 border border-blue-200 rounded px-1.5 py-0.5">
+                <span className="text-xs bg-blue-50 text-blue-700 border border-blue-200 rounded px-1.5 py-0.5">
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<span className="no-print text-xs bg-blue-50 text-blue-700 border border-blue-200 rounded px-1.5 py-0.5">
{revealLabel(block.revealRule)}
</span>
<span className="text-xs bg-blue-50 text-blue-700 border border-blue-200 rounded px-1.5 py-0.5">
{revealLabel(block.revealRule)}
</span>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/app/dashboard/handout/`[id]/print/page.tsx around lines 94 - 96,
The reveal badge is being hidden in printed/PDF output because the span
rendering revealLabel(block.revealRule) includes the "no-print" class; remove
that class (or adjust print CSS to override .no-print to display inline in
`@media` print) so the revealLabel is included in exports. Locate the span that
renders revealLabel(block.revealRule) in page.tsx, remove "no-print" from its
className (or add a print-specific rule that forces .no-print to be visible) and
verify printed/PDF output shows the badge.

Comment on lines +29 to +38
// Viewer-Heartbeat: beim Laden und danach alle 30s
useEffect(() => {
if (sessionInfo?.status === "ended") return;
const viewerId = getViewerId();
const ping = () => pingViewer({ publicToken, viewerId }).catch(() => {});
ping();
const interval = setInterval(ping, 30_000);
return () => clearInterval(interval);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [publicToken, sessionInfo?.status, pingViewer]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

cat -n apps/web/src/app/h/[token]/page.tsx | head -60

Repository: EinfachMxrc/handout

Length of output: 2648


🏁 Script executed:

rg "sessionInfo" apps/web/src/app/h/[token]/page.tsx -B 2 -A 2

Repository: EinfachMxrc/handout

Length of output: 2458


🏁 Script executed:

rg "useQuery\|sessionInfo" apps/web/src -A 3 | head -100

Repository: EinfachMxrc/handout

Length of output: 45


🏁 Script executed:

fd "schema\|type" apps/web/src --type f | grep -i session | head -10

Repository: EinfachMxrc/handout

Length of output: 45


Distinguish loading from not found before starting the heartbeat.

The interval starts while sessionInfo is undefined (loading). If the query later resolves to null (session not found), the dependency on line 38 remains undefined because optional chaining collapses both cases, so the effect never reruns and the page keeps pinging an invalid publicToken every 30 seconds.

Suggested fix
+  const heartbeatState =
+    sessionInfo === undefined ? "loading" : sessionInfo === null ? "missing" : sessionInfo.status;
+
   // Viewer-Heartbeat: beim Laden und danach alle 30s
   useEffect(() => {
-    if (sessionInfo?.status === "ended") return;
+    if (heartbeatState !== "draft" && heartbeatState !== "live") return;
     const viewerId = getViewerId();
     const ping = () => pingViewer({ publicToken, viewerId }).catch(() => {});
     ping();
     const interval = setInterval(ping, 30_000);
     return () => clearInterval(interval);
-  }, [publicToken, sessionInfo?.status, pingViewer]);
+  }, [publicToken, heartbeatState, pingViewer]);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Viewer-Heartbeat: beim Laden und danach alle 30s
useEffect(() => {
if (sessionInfo?.status === "ended") return;
const viewerId = getViewerId();
const ping = () => pingViewer({ publicToken, viewerId }).catch(() => {});
ping();
const interval = setInterval(ping, 30_000);
return () => clearInterval(interval);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [publicToken, sessionInfo?.status, pingViewer]);
const heartbeatState =
sessionInfo === undefined ? "loading" : sessionInfo === null ? "missing" : sessionInfo.status;
// Viewer-Heartbeat: beim Laden und danach alle 30s
useEffect(() => {
if (heartbeatState !== "draft" && heartbeatState !== "live") return;
const viewerId = getViewerId();
const ping = () => pingViewer({ publicToken, viewerId }).catch(() => {});
ping();
const interval = setInterval(ping, 30_000);
return () => clearInterval(interval);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [publicToken, heartbeatState, pingViewer]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/app/h/`[token]/page.tsx around lines 29 - 38, The heartbeat
effect starts while sessionInfo is undefined (loading) and never reruns if the
query resolves to null, so stop/avoid starting pings until sessionInfo is
resolved and ensure the effect reacts to a transition from loading→null; modify
the useEffect guarding logic (the effect around getViewerId/pingViewer/ping) to
only start the interval when sessionInfo !== undefined && sessionInfo !== null
&& sessionInfo.status !== "ended", and include sessionInfo (not just
sessionInfo?.status) in the dependency array so the effect will re-run and clear
the interval when the query resolves to null; reference the useEffect,
sessionInfo, publicToken, getViewerId, and pingViewer identifiers when making
the change.

Comment thread convex/auth.ts Outdated
Comment on lines 30 to 33
const passwordHash = await sha256Hash(args.password);
const presenterId = await ctx.db.insert("presenters", {
email: args.email,
passwordHash,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

fd -t f "auth.ts" convex/ && fd -t f "_utils.ts" convex/

Repository: EinfachMxrc/handout

Length of output: 94


🏁 Script executed:

cat -n convex/auth.ts | head -90

Repository: EinfachMxrc/handout

Length of output: 3172


🏁 Script executed:

cat -n convex/_utils.ts

Repository: EinfachMxrc/handout

Length of output: 2904


Replace SHA-256 with a proper password KDF (bcrypt, Argon2, or PBKDF2).

The sha256Hash function uses a fast cryptographic hash with only a static string prefix as salt. SHA-256 is not a Key Derivation Function and provides insufficient protection:

  • No work factor or iteration count to slow brute-force attacks
  • Static prefix "slide-handout:" offers no per-user randomness
  • Identical passwords hash identically across accounts, leaking information
  • Offline dictionary attacks and precomputed rainbow tables are feasible

Apply this fix to both the registration path (line 30) and the legacy upgrade path (lines 68-80). Use bcrypt, Argon2, or PBKDF2 with appropriate cost/iteration parameters to make password cracking computationally expensive.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@convex/auth.ts` around lines 30 - 33, Replace the use of the fast sha256Hash
for storing passwords with a proper password KDF (e.g., bcrypt or Argon2) when
creating presenter records and in the legacy-upgrade path: stop calling
sha256Hash to produce passwordHash in the registration flow (the assignment that
goes into ctx.db.insert("presenters")), instead generate a per-user salted KDF
hash with a secure cost/work factor and store that; likewise locate the legacy
upgrade logic that checks existing SHA-256 hashes (the branch handling
oldPassword / legacy verification) and update it to verify the old SHA-256
value, then re-hash the password with the chosen KDF and replace the stored
passwordHash. Also ensure any authentication/verify code uses the KDF's verify
function rather than raw SHA-256 comparisons and preserve a migration flag or
scheme identifier so you can recognize legacy hashes.

Comment thread convex/viewers.ts
Comment on lines +30 to +57
args: {
publicToken: v.string(),
viewerId: v.string(),
},
handler: async (ctx, args) => {
const session = await ctx.db
.query("presentationSessions")
.withIndex("by_public_token", (q) => q.eq("publicToken", args.publicToken))
.first();

if (!session || session.status === "ended") return;

const existing = await ctx.db
.query("viewerHeartbeats")
.withIndex("by_session_viewer", (q) =>
q.eq("sessionId", session._id).eq("viewerId", args.viewerId)
)
.first();

if (existing) {
await ctx.db.patch(existing._id, { lastSeenAt: Date.now() });
} else {
await ctx.db.insert("viewerHeartbeats", {
sessionId: session._id,
viewerId: args.viewerId,
lastSeenAt: Date.now(),
});
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Bound viewerId before writing it into an indexed public table.

Lines 31-55 accept an anonymous viewerId as an unrestricted string and persist it into the indexed viewerHeartbeats table. A client can spam oversized or high-cardinality IDs here and turn this public mutation into an avoidable storage/write-amplification vector.

Suggested fix
   handler: async (ctx, args) => {
+    if (args.viewerId.length === 0 || args.viewerId.length > 64) {
+      throw new Error("Ungültige Zuschauer-ID");
+    }
+
     const session = await ctx.db
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@convex/viewers.ts` around lines 30 - 57, The handler currently writes the raw
args.viewerId into the indexed viewerHeartbeats table (functions: handler,
viewerHeartbeats query/insert/patch), which allows unbounded/high-cardinality
values; before any DB read/write, validate and bound viewerId (e.g., enforce
allowed charset and max length) or replace it with a fixed-size fingerprint
(hash) and use that bounded identifier in all queries/patches/inserts so the
index only ever stores a constrained, predictable value; ensure the same
transformation is used for the existing lookup (withIndex("by_session_viewer"))
and for insert/patch operations.

Comment thread convex/viewers.ts
Comment on lines +64 to +79
export const getViewerCount = query({
args: {
token: v.string(),
sessionId: v.id("presentationSessions"),
},
handler: async (ctx, args) => {
await requirePresenter(ctx, args.token);

const cutoff = Date.now() - ACTIVE_WINDOW_MS;
const heartbeats = await ctx.db
.query("viewerHeartbeats")
.withIndex("by_session", (q) => q.eq("sessionId", args.sessionId))
.collect();

return heartbeats.filter((h) => h.lastSeenAt > cutoff).length;
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Authorize the requested session before returning its viewer count.

Line 70 validates the caller's token, but this query never loads args.sessionId or checks that the session belongs to that presenter. Any authenticated presenter who gets another session ID can read that session's viewer count.

Suggested fix
   handler: async (ctx, args) => {
-    await requirePresenter(ctx, args.token);
+    const presenter = await requirePresenter(ctx, args.token);
+    const session = await ctx.db.get(args.sessionId);
+    if (!session || session.presenterId !== presenter._id) {
+      throw new Error("Nicht autorisiert");
+    }
 
     const cutoff = Date.now() - ACTIVE_WINDOW_MS;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export const getViewerCount = query({
args: {
token: v.string(),
sessionId: v.id("presentationSessions"),
},
handler: async (ctx, args) => {
await requirePresenter(ctx, args.token);
const cutoff = Date.now() - ACTIVE_WINDOW_MS;
const heartbeats = await ctx.db
.query("viewerHeartbeats")
.withIndex("by_session", (q) => q.eq("sessionId", args.sessionId))
.collect();
return heartbeats.filter((h) => h.lastSeenAt > cutoff).length;
},
export const getViewerCount = query({
args: {
token: v.string(),
sessionId: v.id("presentationSessions"),
},
handler: async (ctx, args) => {
const presenter = await requirePresenter(ctx, args.token);
const session = await ctx.db.get(args.sessionId);
if (!session || session.presenterId !== presenter._id) {
throw new Error("Nicht autorisiert");
}
const cutoff = Date.now() - ACTIVE_WINDOW_MS;
const heartbeats = await ctx.db
.query("viewerHeartbeats")
.withIndex("by_session", (q) => q.eq("sessionId", args.sessionId))
.collect();
return heartbeats.filter((h) => h.lastSeenAt > cutoff).length;
},
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@convex/viewers.ts` around lines 64 - 79, getViewerCount currently calls
requirePresenter(ctx, args.token) but never verifies that args.sessionId belongs
to that presenter; fix by loading the presentation session and checking
ownership before returning the count: after requirePresenter, fetch the session
via ctx.db.get(args.sessionId) (presentationSessions), ensure the session exists
and its presenter identifier (e.g., session.presenterId or
session.presenterToken) matches the authenticated presenter identity returned by
requirePresenter (or modify requirePresenter to return the presenter id), and
throw an unauthorized error if it does not; only then query viewerHeartbeats and
return the active count.

Comment thread README.md
- [ ] **Auth:** MVP nutzt einfaches Hash-System – für Produktion: Clerk/Auth0/Convex Auth
- [x] **PowerPoint Fullscreen-Sync:** Direkteingabe der Foliennummer im Add-in als Workaround; Add-in wechselt automatisch in Hybrid/Manuell-Modus (Office.js-Limitation bleibt bestehen)
- [x] **Auth:** SHA-256 via Web Crypto API; Bestehende Legacy-Hashes werden beim nächsten Login automatisch migriert
- [x] **Add-in Token-Übergabe:** „Add-in verbinden"-Panel in der Session-Seite – Token, Session-ID und Convex-URL mit einem Klick kopierbar (kein DevTools mehr)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fix the mismatched quotes in the panel name.

„Add-in verbinden"-Panel mixes German opening quotes with an ASCII closing quote. Use „Add-in verbinden“-Panel (or drop the quotes) so the README renders cleanly.

🧰 Tools
🪛 LanguageTool

[typographical] ~244-~244: Zeichen ohne sein Gegenstück: ‚“‘ scheint zu fehlen
Context: ...griert - [x] Add-in Token-Übergabe: „Add-in verbinden"-Panel in der Session-S...

(DE_UNPAIRED_QUOTES)


[typographical] ~244-~244: Zeichen ohne sein Gegenstück: ‚"‘ scheint zu fehlen
Context: ...d-in Token-Übergabe:** „Add-in verbinden"-Panel in der Session-Seite – Token, Ses...

(DE_UNPAIRED_QUOTES)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@README.md` at line 244, The README contains a mismatched-quote string
`„Add-in verbinden"-Panel`; replace the ASCII closing quote with the matching
German closing quote so the text reads `„Add-in verbinden“-Panel` (or remove the
quotes entirely) to ensure consistent quotation marks and correct rendering.

EinfachMxrc and others added 2 commits April 6, 2026 16:14
- print/page.tsx: remove no-print class from reveal badge so labels
  appear in PDF/print output
- h/[token]/page.tsx: guard heartbeat effect against null sessionInfo
  (loading→null transition no longer starts unnecessary pings)
- convex/_utils.ts: add pbkdf2Hash (PBKDF2-SHA256, 100k iter, random
  salt) and verifyPassword helper supporting all hash prefixes
- convex/auth.ts: use pbkdf2Hash for new accounts; auto-upgrade legacy
  sha256_ and mvp_ hashes to pbkdf2_ on successful login
- convex/init.ts: seed demo account with pbkdf2Hash
- convex/viewers.ts: hash viewerId to fixed-size fingerprint before
  storing in index; add session ownership check in getViewerCount

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Added by_lastSeenAt index to viewerHeartbeats table so the hourly cron
can range-scan only stale rows instead of collecting the entire table
(which would hit Convex's 16 384-document query limit at scale).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant