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();
+ },
+ };
+}