From 5f36ff0b7d8daa00abad428d1cc2d04da8c7ef9f Mon Sep 17 00:00:00 2001 From: 1ynx Date: Wed, 6 May 2026 23:17:50 +0200 Subject: [PATCH] + create user, login, logout --- package-lock.json | 14 ++++ package.json | 3 +- src/app.ts | 86 +++++++++++++++++++-- src/components/PollList.ts | 8 +- src/components/StatusBar.ts | 145 +++++++++++++++++++++++++++++++----- src/crypto.ts | 119 +++++++++++++++++++++++++++++ src/state.ts | 6 ++ src/styles.css | 8 ++ src/userSync.ts | 70 +++++++++++++++++ 9 files changed, 430 insertions(+), 29 deletions(-) create mode 100644 src/crypto.ts create mode 100644 src/userSync.ts diff --git a/package-lock.json b/package-lock.json index f4d1151..8166a4e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "polly-p2p-poll", "version": "1.0.0", "dependencies": { + "uuid": "^13.0.0", "y-indexeddb": "^9.0.12", "y-webrtc": "^10.3.0", "yjs": "^13.6.27" @@ -1288,6 +1289,19 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/uuid": { + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.2.tgz", + "integrity": "sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, "node_modules/vite": { "version": "7.3.2", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", diff --git a/package.json b/package.json index bc36fd7..5d9deba 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ "dependencies": { "y-indexeddb": "^9.0.12", "y-webrtc": "^10.3.0", - "yjs": "^13.6.27" + "yjs": "^13.6.27", + "uuid": "^13.0.0" }, "devDependencies": { "typescript": "^5.9.2", diff --git a/src/app.ts b/src/app.ts index b6ad391..6b89ada 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,18 +1,22 @@ import { getUserId } from "./identity"; import { + User, addOption, toggleVote, setDeadline, clearDeadline, getDeadline, } from "./state"; +import { v4 as uuidv4 } from 'uuid'; import { initSync } from "./sync"; +import { initUserSync } from "./userSync"; import { StatusBar } from "./components/StatusBar"; import { PollTitle } from "./components/PollTitle"; import { AddOption } from "./components/AddOption"; import { PollList } from "./components/PollList"; import { ShareSection } from "./components/ShareSection"; import { DeadlineTimer } from "./components/DeadlineTimer"; +import { generateUserKeyPair, exportPrivateKey, savePrivateKeyToFile, exportPublicKey, stringToCryptoKey } from "./crypto"; const ROOM_PARAM = "room"; @@ -38,8 +42,9 @@ function ensureRoomId(): string { export function initApp(container: HTMLElement): () => void { const roomId = ensureRoomId(); - const userId = getUserId(); const sync = initSync(roomId); + const userSync = initUserSync(); + let user : User | undefined = undefined; const shareUrl = window.location.href; @@ -47,12 +52,12 @@ export function initApp(container: HTMLElement): () => void { const actions = { addOption: (label: string) => { - if (isVotingClosed()) return; - return addOption(sync.options, label, userId); + if (!user || isVotingClosed()) return; + return addOption(sync.options, label, user.userid); }, toggleVote: (optionId: string) => { - if (isVotingClosed()) return; - toggleVote(sync.votes, userId, optionId); + if (!user || isVotingClosed()) return; + toggleVote(sync.votes, user.userid, optionId); }, startDeadline: (durationMs: number) => { setDeadline(sync.deadlineMap, durationMs); @@ -60,6 +65,73 @@ export function initApp(container: HTMLElement): () => void { clearDeadline: () => { clearDeadline(sync.deadlineMap); }, + onLoginLogout: async (event: Event) => { + if(user){ + user = undefined + return false; + } else { + const target = event.target as HTMLInputElement; + const file = target.files?.[0]; + + if (file) { + try { + const content = await file.text(); + console.log("File loaded: "); + if (file.name && content) { + try { + const uuid = file.name.replace(".pem", ""); + // Standardize the string for the importer + const pkBase64 = content.replace(/-----BEGIN PRIVATE KEY-----|-----END PRIVATE KEY-----/g, "").replace(/\s+/g, ""); + + const key = await stringToCryptoKey(pkBase64, "private"); + + user = { + userid: uuid, + private_key: key, + public_key: undefined, // Note: You might need to import a pub key too! + }; + + console.log("Login successful for:", uuid); + return true; + } catch (err) { + console.error("Crypto Import Error:", err); + alert("The file content is not a valid Private Key."); + } + } + } catch (e) { + console.error("Failed to read file", e); + } + } + return false; + } + }, + onCreateUser: async (event: Event) => { + try { + const keypair = await generateUserKeyPair(); + + console.log('keypair:', keypair); + const uuid = uuidv4(); + user = { + userid: uuid, + private_key: keypair.privateKey, + public_key: keypair.publicKey, + }; + + const prvKeyString = await exportPrivateKey(keypair.privateKey); + + savePrivateKeyToFile(prvKeyString,uuid+".pem") + + + const pubKeyString = await exportPublicKey(keypair.publicKey); + + userSync.users.set(user.userid,pubKeyString) + return true; + } catch (err) { + user = undefined + console.error("Failed to create new User!", err); + } + return false; + } }; function isVotingClosed() { @@ -85,7 +157,7 @@ export function initApp(container: HTMLElement): () => void { Polly `; - const statusBar = StatusBar(sync.provider); + const statusBar = StatusBar(sync.provider,userSync.provider,actions.onLoginLogout,actions.onCreateUser); header.append(wordmark, statusBar); // Main card @@ -98,7 +170,7 @@ export function initApp(container: HTMLElement): () => void { if (result && !result.ok) return result.error; return null; }); - const pollList = PollList(sync.options, sync.votes, userId, isVotingClosed, actions.toggleVote); + const pollList = PollList(sync.options, sync.votes, user, isVotingClosed, actions.toggleVote); const deadlineTimer = DeadlineTimer( sync.deadlineMap, actions.startDeadline, diff --git a/src/components/PollList.ts b/src/components/PollList.ts index 4828495..a4f909d 100644 --- a/src/components/PollList.ts +++ b/src/components/PollList.ts @@ -2,11 +2,12 @@ import * as Y from "yjs"; import type { OptionRecord } from "../state"; import { PollOption } from "./PollOption"; import { enforceAppendOnly } from "../yDocUtil"; +import { User } from "../state"; export function PollList( yOptions: Y.Map, yVotes: Y.Map, - userId: string, + user: User | undefined, isVotingClosed: () => boolean, onVote: (optionId: string) => void, ): HTMLElement { @@ -53,7 +54,10 @@ export function PollList( tally.set(optionId, (tally.get(optionId) ?? 0) + 1); } - const myVote = currentVotes[userId] ?? null; + let myVote = null; + if (user) { + myVote = currentVotes[user.userid] + } Object.entries(currentOptions).forEach(([id,record]) => { console.log(`${record}: ${id}`) diff --git a/src/components/StatusBar.ts b/src/components/StatusBar.ts index d981c0a..52d9950 100644 --- a/src/components/StatusBar.ts +++ b/src/components/StatusBar.ts @@ -1,30 +1,110 @@ import type { WebrtcProvider } from "y-webrtc"; -export function StatusBar(provider: WebrtcProvider): HTMLElement { +export function StatusBar(provider: WebrtcProvider, user_provider: WebrtcProvider, onLoginLogout: (event: Event) => Promise, onCreateUser: (event: Event) => Promise): HTMLElement { const el = document.createElement("div"); el.className = "status-bar"; - const dot = document.createElement("span"); - dot.className = "status-dot connecting"; - - const statusText = document.createElement("span"); - statusText.className = "status-text"; - statusText.textContent = "Connecting"; + const statusPanel=document.createElement("div"); + statusPanel.className = "status-bar"; const divider = document.createElement("span"); divider.className = "status-divider"; divider.textContent = "\u00b7"; - const peerText = document.createElement("span"); - peerText.className = "status-peers"; + function getProviderStatus(){ - el.append(dot, statusText, divider, peerText); + const dot = document.createElement("span"); + dot.className = "status-dot connecting"; + + const statusText = document.createElement("span"); + statusText.className = "status-text"; + statusText.textContent = "Connecting"; + + const peerText = document.createElement("span"); + peerText.className = "status-peers"; + + return { dot: dot, statusText: statusText, peerText: peerText} + } + + const providerStatusPanel=document.createElement("div"); + providerStatusPanel.className = "provider-status-container"; + + + const pollProviderText = document.createElement("span"); + pollProviderText.className = "status-text"; + pollProviderText.textContent = "Polls: "; + const pollProviderElements = getProviderStatus() + const pollProviderStatusPanel=document.createElement("div"); + pollProviderStatusPanel.className = "status-bar"; + pollProviderStatusPanel.append(pollProviderText,pollProviderElements.dot, pollProviderElements.statusText, divider, pollProviderElements.peerText); + + + const userProviderText = document.createElement("span"); + userProviderText.className = "status-text"; + userProviderText.textContent = "Users: "; + const userProviderElements = getProviderStatus() + const userProviderStatusPanel=document.createElement("div"); + userProviderStatusPanel.className = "status-bar"; + userProviderStatusPanel.append(userProviderText,userProviderElements.dot, userProviderElements.statusText, divider, userProviderElements.peerText); + + providerStatusPanel.append(userProviderStatusPanel,pollProviderStatusPanel) + + const userButtons = document.createElement("div"); + userButtons.className = "status-bar"; + + const loginLabel = document.createElement("label"); + loginLabel.setAttribute("title", "Select Key File"); + const loginSpan = document.createElement("span"); + loginSpan.className = "add-option-btn" + loginSpan.textContent = "Login"; + const loginInput = document.createElement("input"); + loginInput.type = "file" + loginInput.accept = ".pem" + loginInput.hidden = true + + + loginLabel.append(loginSpan,loginInput) + + + const logoutButton = document.createElement("button"); + logoutButton.className = "add-option-btn"; + logoutButton.setAttribute("aria-label", "Logout"); + logoutButton.innerHTML="Logout" + logoutButton.style.display = "none"; + + const createUserButton = document.createElement("button"); + createUserButton.className = "add-option-btn"; + createUserButton.setAttribute("aria-label", "Create User"); + createUserButton.innerHTML="Create User" + + async function onLoginLogoutResult(event: Event, loginLogout: (event: Event) => Promise){ + if(await loginLogout(event)){ + console.log('created / logged in') + loginLabel.style.display = "none"; + logoutButton.style.display = "block"; + createUserButton.style.display = "none"; + } else { + console.log('logged out') + loginLabel.style.display = "block"; + logoutButton.style.display = "none"; + createUserButton.hidden = false; + createUserButton.style.display = "block"; + } + } + + loginLabel.addEventListener("change", (e) => onLoginLogoutResult(e,onLoginLogout)); + logoutButton.addEventListener("click", (e) => onLoginLogoutResult(e,onLoginLogout)); + createUserButton.addEventListener("click", (e) => onLoginLogoutResult(e,onCreateUser)); + + userButtons.append(loginLabel,logoutButton,createUserButton) + + el.append(providerStatusPanel, divider, userButtons); // --- Connection state --- let syncTimeout: ReturnType | undefined = setTimeout(() => { - statusText.textContent = "Ready"; - dot.className = "status-dot ready"; + pollProviderElements.statusText.textContent = "Ready"; + pollProviderElements.dot.className = "status-dot ready"; }, 3000); provider.on("synced", ({ synced }: { synced: boolean }) => { @@ -32,18 +112,37 @@ export function StatusBar(provider: WebrtcProvider): HTMLElement { clearTimeout(syncTimeout); syncTimeout = undefined; } - dot.className = `status-dot ${synced ? "connected" : "connecting"}`; - statusText.textContent = synced ? "Connected" : "Connecting"; + pollProviderElements.dot.className = `status-dot ${synced ? "connected" : "connecting"}`; + pollProviderElements.statusText.textContent = synced ? "Connected" : "Connecting"; + }); + + + let syncTimeout2: ReturnType | undefined = setTimeout(() => { + userProviderElements.statusText.textContent = "Ready"; + userProviderElements.dot.className = "status-dot ready"; + }, 3000); + + user_provider.on("synced", ({ synced }: { synced: boolean }) => { + if (syncTimeout2) { + clearTimeout(syncTimeout2); + syncTimeout2 = undefined; + } + userProviderElements.dot.className = `status-dot ${synced ? "connected" : "connecting"}`; + userProviderElements.statusText.textContent = synced ? "Connected" : "Connecting"; }); // Online/offline awareness const handleOffline = () => { - dot.className = "status-dot connecting"; - statusText.textContent = "Offline"; + pollProviderElements.dot.className = "status-dot connecting"; + pollProviderElements.statusText.textContent = "Offline"; + userProviderElements.dot.className = "status-dot connecting"; + userProviderElements.statusText.textContent = "Offline"; }; const handleOnline = () => { - dot.className = "status-dot connecting"; - statusText.textContent = "Reconnecting"; + pollProviderElements.dot.className = "status-dot connecting"; + pollProviderElements.statusText.textContent = "Reconnecting"; + userProviderElements.dot.className = "status-dot connecting"; + userProviderElements.statusText.textContent = "Reconnecting"; }; window.addEventListener("offline", handleOffline); @@ -54,10 +153,18 @@ export function StatusBar(provider: WebrtcProvider): HTMLElement { function updatePeerCount() { const total = provider.awareness.getStates().size; const others = total - 1; - peerText.textContent = + pollProviderElements.peerText.textContent = others === 0 ? "Only you" : `${others} other${others !== 1 ? "s" : ""}`; + + + const total2 = user_provider.awareness.getStates().size; + const others2 = total2 - 1; + userProviderElements.peerText.textContent = + others2 === 0 + ? "Only you" + : `${others2} other${others2 !== 1 ? "s" : ""}`; } provider.awareness.on("change", updatePeerCount); diff --git a/src/crypto.ts b/src/crypto.ts new file mode 100644 index 0000000..91d9b0f --- /dev/null +++ b/src/crypto.ts @@ -0,0 +1,119 @@ +export const generateUserKeyPair = async () => { + return await window.crypto.subtle.generateKey( + { + name: "RSASSA-PKCS1-v1_5", + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), // 65537 + hash: "SHA-256", + }, + true, // extractable + ["sign", "verify"] + ); +}; + +export const signData = async (data: any, privateKey: CryptoKey) => { + const encoder = new TextEncoder(); + const encodedData = encoder.encode(JSON.stringify(data)); + + const signature = await window.crypto.subtle.sign( + "RSASSA-PKCS1-v1_5", + privateKey, + encodedData + ); + + // Convert to Base64 or Hex to store in Yjs easily + return btoa(String.fromCharCode(...new Uint8Array(signature))); +}; + + +// Helper to convert ArrayBuffer to Base64 string +const bufferToBase64 = (buf: ArrayBuffer) => + window.btoa(String.fromCharCode(...new Uint8Array(buf))); + +export const exportPublicKey = async (key: CryptoKey) => { + // Export Public Key + const exportedPublic = await window.crypto.subtle.exportKey("spki", key); + const publicKeyString = bufferToBase64(exportedPublic); + + return publicKeyString; +}; +export const exportPrivateKey = async (key: CryptoKey) => { + // Export Private Key + const exportedPrivate = await window.crypto.subtle.exportKey("pkcs8", key); + const privateKeyString = bufferToBase64(exportedPrivate); + + return privateKeyString; +}; + +/** + * Converts a Base64 string back into a usable CryptoKey object + * @param keyStr The Base64 string (without PEM headers) + * @param type 'public' or 'private' + */ +export const stringToCryptoKey = async (keyStr: string, type: 'public' | 'private'): Promise => { + // 1. Convert Base64 string to a Uint8Array (binary) + const binaryString = window.atob(keyStr); + const bytes = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + + // 2. Identify the format based on the key type + // Public keys usually use 'spki', Private keys use 'pkcs8' + const format = type === 'public' ? 'spki' : 'pkcs8'; + const usages: KeyUsage[] = type === 'public' ? ['verify'] : ['sign']; + + // 3. Import the key + return await window.crypto.subtle.importKey( + format, + bytes.buffer, + { + name: "RSASSA-PKCS1-v1_5", + hash: "SHA-256", + }, + true, // extractable (set to false if you want to lock it in memory) + usages + ); +}; + +export const savePrivateKeyToFile = (privateKeyStr: string, filename: string) => { + // Optional: Wrap in PEM headers for standard formatting + const pemHeader = "-----BEGIN PRIVATE KEY-----\n"; + const pemFooter = "\n-----END PRIVATE KEY-----"; + const fileContent = pemHeader + privateKeyStr + pemFooter; + + const blob = new Blob([fileContent], { type: "text/plain" }); + const url = URL.createObjectURL(blob); + + const link = document.createElement("a"); + link.href = url; + link.download = filename; + + document.body.appendChild(link); + link.click(); + + // Cleanup + document.body.removeChild(link); + URL.revokeObjectURL(url); +}; + +export const loadPrivateKeyFromFile = async (file: File): Promise => { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + + reader.onload = (e) => { + const content = e.target?.result as string; + + // Clean up the string by removing PEM headers and newlines + const cleanKey = content + .replace("-----BEGIN PRIVATE KEY-----", "") + .replace("-----END PRIVATE KEY-----", "") + .replace(/\s+/g, ""); // Removes all whitespace/newlines + + resolve(cleanKey); + }; + + reader.onerror = () => reject("Error reading file"); + reader.readAsText(file); + }); +}; \ No newline at end of file diff --git a/src/state.ts b/src/state.ts index 0d08971..fc20f17 100644 --- a/src/state.ts +++ b/src/state.ts @@ -4,6 +4,12 @@ import * as Y from "yjs"; export type ConnectionStatus = "connecting" | "connected" | "offline"; +export interface User{ + userid: string, + private_key: CryptoKey, + public_key: CryptoKey | undefined, +} + export interface OptionRecord { id: string; label: string; diff --git a/src/styles.css b/src/styles.css index 965045c..e213662 100644 --- a/src/styles.css +++ b/src/styles.css @@ -86,6 +86,14 @@ button { cursor: pointer; } font-size: 0.8rem; color: var(--text-secondary); } +.provider-status-container { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.4rem; + font-size: 0.8rem; + color: var(--text-secondary); +} .status-dot { width: 6px; diff --git a/src/userSync.ts b/src/userSync.ts new file mode 100644 index 0000000..5ba0f51 --- /dev/null +++ b/src/userSync.ts @@ -0,0 +1,70 @@ +import { IndexeddbPersistence } from "y-indexeddb"; +import { WebrtcProvider } from "y-webrtc"; +import * as Y from "yjs"; + +import type { ConnectionStatus, OptionRecord } from "./state"; + +export interface UserSync { + doc: Y.Doc; + users: Y.Map; + provider: WebrtcProvider; + persistence: IndexeddbPersistence; + getConnectionStatus: () => ConnectionStatus; + getPeerCount: () => number; + destroy: () => void; +} + +export function initUserSync(): UserSync { + const doc = new Y.Doc(); + const users = doc.getMap("users"); + + let connectionStatus: ConnectionStatus = navigator.onLine + ? "connecting" + : "offline"; + + const provider = new WebrtcProvider("users", doc,{ + signaling: ["ws://localhost:4444", "ws://lynxpi.ddns.net:4444"] + }); + const persistence = new IndexeddbPersistence("users", doc); + + const syncConnectionStatus = (status: ConnectionStatus) => { + connectionStatus = navigator.onLine ? status : "offline"; + }; + + const handleOnline = () => { + syncConnectionStatus(provider.connected ? "connected" : "connecting"); + }; + const handleOffline = () => { + connectionStatus = "offline"; + }; + + provider.on("status", (event: { connected: boolean }) => { + syncConnectionStatus(event.connected ? "connected" : "connecting"); + }); + + provider.on("synced", ({ synced }: { synced: boolean }) => { + if (synced) syncConnectionStatus("connected"); + }); + + window.addEventListener("online", handleOnline); + window.addEventListener("offline", handleOffline); + + return { + doc, + users, + provider, + persistence, + getConnectionStatus: () => connectionStatus, + getPeerCount: () => { + const total = provider.awareness.getStates().size; + return Math.max(0, total - 1); + }, + destroy: () => { + window.removeEventListener("online", handleOnline); + window.removeEventListener("offline", handleOffline); + persistence.destroy(); + provider.destroy(); + doc.destroy(); + }, + }; +}