Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -239,14 +239,14 @@ Folie 2 (zurück) → A + B noch sichtbar (highWaterSlide=5) ✓

## Bekannte Grenzen / TODOs

- [ ] **PowerPoint Fullscreen-Sync:** Im Vollbild-Präsentationsmodus kein zuverlässiger Auto-Sync (Office.js-Limitation) – Workaround: Hybrid/Manuell-Modus
- [ ] **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.

- [x] **Block-Reihenfolge:** Drag-and-Drop implementiert (⠿ Handle ziehen) + ↑/↓-Buttons bleiben als Fallback
- [x] **Markdown-Editor:** Vorschau-Tab im Block-Editor hinzugefügt
- [ ] **Mehrere aktive Sessions:** Derzeit wird immer die neueste gezeigt
- [ ] **Zuschauer-Count:** Keine Anzeige, wie viele Zuschauer das Handout gerade lesen
- [ ] **Export:** PDF-Export über Browser-Druck – nativer Export wäre besser
- [x] **Mehrere aktive Sessions:** Dashboard warnt wenn mehrere Live-Sessions für dasselbe Handout laufen
- [x] **Zuschauer-Count:** Echtzeit-Anzeige in der Session-Seite via 30s-Heartbeat (`viewerHeartbeats`-Tabelle, stündliche Bereinigung)
- [x] **Export:** Dedizierte Druckseite `/dashboard/handout/[id]/print` mit allen Blöcken + Reveal-Badges, öffnet Print-Dialog automatisch
- [x] **Demo-Seeding:** Automatisch beim `pnpm dev:convex` via `--run init:init`

---
Expand Down
30 changes: 30 additions & 0 deletions apps/powerpoint-addin/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export function App({ convexReady }: AppProps) {
const simulatorRef = useRef<SlideSimulator | null>(null);

const [officeMode, setOfficeMode] = useState<"unknown" | SyncCapability>("unknown");
const [slideInput, setSlideInput] = useState("");

const setCurrentSlide = useMutation(api.sessions.setCurrentSlide);

Expand Down Expand Up @@ -166,6 +167,35 @@ export function App({ convexReady }: AppProps) {
</div>
</div>

{/* Direkte Foliennummer-Eingabe (hilfreich im Vollbild-Modus) */}
<div>
<p className="text-xs font-medium text-gray-500 mb-1">Folie direkt eingeben</p>
<form
className="flex gap-2"
onSubmit={(e) => {
e.preventDefault();
const n = parseInt(slideInput, 10);
if (!isNaN(n) && n >= 1) {
store.setLastKnownSlide(n);
syncSlide(n);
setSlideInput("");
}
}}
>
<input
type="number"
min={1}
value={slideInput}
onChange={(e) => setSlideInput(e.target.value)}
placeholder="Nr."
className="input-sm w-16 text-center"
/>
<button type="submit" className="btn-secondary flex-1 text-xs">
Setzen
</button>
</form>
</div>

{/* Sync mode explanation */}
{store.syncStatus === "auto" && (
<div className="text-xs text-green-700 bg-green-50 border border-green-200 rounded p-2">
Expand Down
7 changes: 7 additions & 0 deletions apps/web/src/app/dashboard/handout/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,13 @@ export default function HandoutEditPage() {
<button className="btn-secondary text-sm" onClick={openEditHandout}>
Bearbeiten
</button>
<Link
href={`/dashboard/handout/${handoutId}/print`}
target="_blank"
className="btn-secondary text-sm"
>
Exportieren
</Link>
<button className="btn-primary text-sm" onClick={handleStartSession}>
Session starten
</button>
Expand Down
114 changes: 114 additions & 0 deletions apps/web/src/app/dashboard/handout/[id]/print/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
"use client";

import { useQuery } from "convex/react";
import { useParams } from "next/navigation";
import { useEffect } from "react";
import Link from "next/link";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { api } from "@convex/_generated/api";
import { useAuthStore } from "@/store/authStore";
import type { Id } from "@convex/_generated/dataModel";

export default function HandoutPrintPage() {
const params = useParams();
const handoutId = params.id as string;
const { token } = useAuthStore();

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

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.


// Print-Dialog automatisch öffnen wenn Daten geladen
useEffect(() => {
if (data) {
const timer = setTimeout(() => window.print(), 300);
return () => clearTimeout(timer);
}
}, [!!data]);
Comment on lines +24 to +29

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


const revealLabel = (rule: any): string => {
if (rule.alwaysVisible) return "Immer sichtbar";
if (rule.manuallyTriggered) return "Manuell";
let label = `Ab Folie ${rule.revealSlide}`;
if (rule.revealToSlide) label += `–${rule.revealToSlide}`;
if (rule.relockOnBack) label += " ↩";
return label;
};

if (!data) {
return <div className="p-8 text-gray-500">Lädt...</div>;
}

const blocks = [...data.blocks].sort((a, b) => a.order - b.order);

return (
<div className="min-h-screen bg-white">
<style>{`
@media print {
.no-print { display: none !important; }
body { font-size: 11pt; }
.handout-block { break-inside: avoid; page-break-inside: avoid; }
}
`}</style>

{/* Toolbar – wird nicht gedruckt */}
<div className="no-print sticky top-0 bg-gray-50 border-b border-gray-200 px-6 py-3 flex items-center justify-between">
<div className="flex items-center gap-3">
<Link
href={`/dashboard/handout/${handoutId}`}
className="text-sm text-gray-500 hover:text-gray-700"
>
← Zurück
</Link>
<span className="text-gray-300">|</span>
<span className="text-sm font-medium text-gray-700">Druckvorschau – alle Blöcke</span>
</div>
<button
onClick={() => window.print()}
className="btn-primary text-sm"
>
Drucken / Als PDF speichern
</button>
</div>

{/* Druckinhalt */}
<div className="max-w-2xl mx-auto px-6 py-8">
<div className="mb-8 pb-4 border-b border-gray-300">
<h1 className="text-2xl font-bold text-gray-900">{data.title}</h1>
{data.description && (
<p className="text-gray-600 mt-1">{data.description}</p>
)}
<p className="text-xs text-gray-400 mt-2 no-print">
{blocks.length} Blöcke · Reveal-Regeln sind als Badge sichtbar · Reihenfolge wie im Editor
</p>
</div>

<div className="space-y-6">
{blocks.map((block, idx) => (
<div key={block._id} className="handout-block">
<div className="flex items-baseline gap-2 mb-2">
<span className="text-xs text-gray-400 no-print">#{idx + 1}</span>
<h2 className="text-base font-semibold text-gray-900">{block.title}</h2>
<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>
</div>
<div className="prose prose-sm max-w-none markdown-content text-gray-700">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{block.content}</ReactMarkdown>
</div>
{idx < blocks.length - 1 && (
<hr className="mt-6 border-gray-200" />
)}
</div>
))}
</div>

<div className="mt-8 pt-4 border-t border-gray-200 text-xs text-gray-400 no-print">
Slide Handout · {new Date().toLocaleDateString("de-DE")}
</div>
</div>
</div>
);
}
14 changes: 14 additions & 0 deletions apps/web/src/app/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,20 @@ export default function DashboardPage() {
{/* Sessions Tab */}
{activeTab === "sessions" && (
<div>
{/* Warnung: mehrere Live-Sessions für dasselbe Handout */}
{sessions && (() => {
const liveCounts: Record<string, number> = {};
sessions.forEach((s) => {
if (s.status === "live") liveCounts[s.handoutId] = (liveCounts[s.handoutId] ?? 0) + 1;
});
const conflicts = Object.values(liveCounts).filter((c) => c > 1).length;
return conflicts > 0 ? (
<div className="mb-4 bg-yellow-50 border border-yellow-200 rounded-lg px-4 py-3 text-sm text-yellow-800">
⚠️ Es laufen mehrere Live-Sessions für dasselbe Handout gleichzeitig. Beende nicht mehr benötigte Sessions.
</div>
) : null;
})()}

{!sessions ? (
<div className="text-center py-12 text-gray-500">Lädt...</div>
) : sessions.length === 0 ? (
Expand Down
11 changes: 11 additions & 0 deletions apps/web/src/app/dashboard/session/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ export default function SessionPage() {
const triggerBlock = useMutation(api.sessions.triggerBlockManually);
const unTriggerBlock = useMutation(api.sessions.unTriggerBlockManually);

const viewerCount = useQuery(
api.viewers.getViewerCount,
token && data ? { token, sessionId: sessionId as Id<"presentationSessions"> } : "skip"
);

const [isQROpen, setIsQROpen] = useState(false);
const [activeView, setActiveView] = useState<"control" | "preview">("control");
const [isAddinOpen, setIsAddinOpen] = useState(false);
Expand Down Expand Up @@ -88,6 +93,12 @@ export default function SessionPage() {
<Badge variant={statusColor[session.status] ?? "gray"}>
{statusLabel[session.status] ?? session.status}
</Badge>
{session.status === "live" && viewerCount !== undefined && (
<span className="text-sm text-gray-500 flex items-center gap-1">
<span className="w-1.5 h-1.5 rounded-full bg-green-400 inline-block" />
{viewerCount} {viewerCount === 1 ? "Zuschauer" : "Zuschauer"}
</span>
)}
</div>

<div className="flex gap-2">
Expand Down
25 changes: 24 additions & 1 deletion apps/web/src/app/h/[token]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,41 @@
"use client";

import { useQuery } from "convex/react";
import { useQuery, useMutation } from "convex/react";
import { useParams } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { api } from "@convex/_generated/api";

/** Stabile Viewer-ID für diese Browser-Session (nicht persistent über Tabs) */
function getViewerId(): string {
const key = "slide-handout-viewer-id";
let id = sessionStorage.getItem(key);
if (!id) {
id = Math.random().toString(36).slice(2) + Date.now().toString(36);
sessionStorage.setItem(key, id);
}
return id;
}

export default function PublicHandoutPage() {
const params = useParams();
const publicToken = params.token as string;

const sessionInfo = useQuery(api.sessions.getPublicSession, { publicToken });
const visibleBlocks = useQuery(api.sessions.getVisibleBlocksForPublic, { publicToken });
const pingViewer = useMutation(api.viewers.pingViewer);

// Viewer-Heartbeat: beim Laden und danach alle 30s
useEffect(() => {
if (!sessionInfo || 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]);
Comment on lines +29 to +38

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.


// useRef instead of useState to avoid stale-closure bugs in Strict Mode:
// mutations to prevBlockIds are synchronous and don't trigger extra renders.
Expand Down
80 changes: 74 additions & 6 deletions convex/_utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,20 +19,88 @@ export function generateToken(length: number = 12): string {
}

/**
* Simple hash for MVP auth – NOT for production use.
* Centralised here so auth.ts and seed.ts always use the same implementation.
* SHA-256 password hash using the Web Crypto API.
* Kept for verifying legacy accounts created before PBKDF2 migration.
* Do NOT use for new accounts — use pbkdf2Hash instead.
* Hash format: "sha256_<hex>"
*/
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}`;
Comment on lines +27 to +34

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

}

/**
* PBKDF2-SHA256 password hash with a random per-user salt.
* Uses Web Crypto API available in Convex's V8 isolate.
* Hash format: "pbkdf2_<saltHex>_<hashHex>"
*
* NOTE: This is a 32-bit integer hash, not bcrypt. It is intentionally
* lightweight for an MVP and must be replaced with a proper password-hashing
* library (e.g. bcrypt via a Convex Action) before production deployment.
* @param password Plaintext password
* @param saltHex Optional 32-char hex salt (re-derive for verification); if
* omitted a fresh random 16-byte salt is generated.
*/
export async function pbkdf2Hash(password: string, saltHex?: string): Promise<string> {
const saltBytes = saltHex
? new Uint8Array(saltHex.match(/.{2}/g)!.map((b) => parseInt(b, 16)))
: crypto.getRandomValues(new Uint8Array(16));
const saltHexOut = Array.from(saltBytes)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");

const key = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(password),
"PBKDF2",
false,
["deriveBits"]
);
const bits = await crypto.subtle.deriveBits(
{ name: "PBKDF2", hash: "SHA-256", salt: saltBytes, iterations: 100_000 },
key,
256
);
const hashHex = Array.from(new Uint8Array(bits))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
return `pbkdf2_${saltHexOut}_${hashHex}`;
}

/**
* Verify a password against any stored hash format (pbkdf2_, sha256_, mvp_).
* Returns true if the password matches.
*/
export async function verifyPassword(password: string, stored: string): Promise<boolean> {
if (stored.startsWith("pbkdf2_")) {
const parts = stored.split("_");
if (parts.length !== 3) return false;
const expected = await pbkdf2Hash(password, parts[1]);
return expected === stored;
}
if (stored.startsWith("sha256_")) {
return (await sha256Hash(password)) === stored;
}
if (stored.startsWith("mvp_")) {
return simpleHash(password) === stored;
}
return false;
}

/**
* Legacy 32-bit hash (MVP only). Kept for migration: existing accounts
* that still have an mvp_-prefixed hash are upgraded to SHA-256 on next login.
* Do NOT use for new accounts.
*/
export function simpleHash(password: string): string {
let hash = 0;
const salted = `slide-handout-mvp:${password}`;
for (let i = 0; i < salted.length; i++) {
const char = salted.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash = hash & hash; // 32-bit truncation
hash = hash & hash;
}
return `mvp_${Math.abs(hash).toString(16)}`;
}
Expand Down
Loading