+ create user, login, logout

This commit is contained in:
2026-05-06 23:17:50 +02:00
parent 424799692a
commit 5f36ff0b7d
9 changed files with 430 additions and 29 deletions

14
package-lock.json generated
View File

@@ -8,6 +8,7 @@
"name": "polly-p2p-poll", "name": "polly-p2p-poll",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"uuid": "^13.0.0",
"y-indexeddb": "^9.0.12", "y-indexeddb": "^9.0.12",
"y-webrtc": "^10.3.0", "y-webrtc": "^10.3.0",
"yjs": "^13.6.27" "yjs": "^13.6.27"
@@ -1288,6 +1289,19 @@
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT" "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": { "node_modules/vite": {
"version": "7.3.2", "version": "7.3.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",

View File

@@ -11,7 +11,8 @@
"dependencies": { "dependencies": {
"y-indexeddb": "^9.0.12", "y-indexeddb": "^9.0.12",
"y-webrtc": "^10.3.0", "y-webrtc": "^10.3.0",
"yjs": "^13.6.27" "yjs": "^13.6.27",
"uuid": "^13.0.0"
}, },
"devDependencies": { "devDependencies": {
"typescript": "^5.9.2", "typescript": "^5.9.2",

View File

@@ -1,18 +1,22 @@
import { getUserId } from "./identity"; import { getUserId } from "./identity";
import { import {
User,
addOption, addOption,
toggleVote, toggleVote,
setDeadline, setDeadline,
clearDeadline, clearDeadline,
getDeadline, getDeadline,
} from "./state"; } from "./state";
import { v4 as uuidv4 } from 'uuid';
import { initSync } from "./sync"; import { initSync } from "./sync";
import { initUserSync } from "./userSync";
import { StatusBar } from "./components/StatusBar"; import { StatusBar } from "./components/StatusBar";
import { PollTitle } from "./components/PollTitle"; import { PollTitle } from "./components/PollTitle";
import { AddOption } from "./components/AddOption"; import { AddOption } from "./components/AddOption";
import { PollList } from "./components/PollList"; import { PollList } from "./components/PollList";
import { ShareSection } from "./components/ShareSection"; import { ShareSection } from "./components/ShareSection";
import { DeadlineTimer } from "./components/DeadlineTimer"; import { DeadlineTimer } from "./components/DeadlineTimer";
import { generateUserKeyPair, exportPrivateKey, savePrivateKeyToFile, exportPublicKey, stringToCryptoKey } from "./crypto";
const ROOM_PARAM = "room"; const ROOM_PARAM = "room";
@@ -38,8 +42,9 @@ function ensureRoomId(): string {
export function initApp(container: HTMLElement): () => void { export function initApp(container: HTMLElement): () => void {
const roomId = ensureRoomId(); const roomId = ensureRoomId();
const userId = getUserId();
const sync = initSync(roomId); const sync = initSync(roomId);
const userSync = initUserSync();
let user : User | undefined = undefined;
const shareUrl = window.location.href; const shareUrl = window.location.href;
@@ -47,12 +52,12 @@ export function initApp(container: HTMLElement): () => void {
const actions = { const actions = {
addOption: (label: string) => { addOption: (label: string) => {
if (isVotingClosed()) return; if (!user || isVotingClosed()) return;
return addOption(sync.options, label, userId); return addOption(sync.options, label, user.userid);
}, },
toggleVote: (optionId: string) => { toggleVote: (optionId: string) => {
if (isVotingClosed()) return; if (!user || isVotingClosed()) return;
toggleVote(sync.votes, userId, optionId); toggleVote(sync.votes, user.userid, optionId);
}, },
startDeadline: (durationMs: number) => { startDeadline: (durationMs: number) => {
setDeadline(sync.deadlineMap, durationMs); setDeadline(sync.deadlineMap, durationMs);
@@ -60,6 +65,73 @@ export function initApp(container: HTMLElement): () => void {
clearDeadline: () => { clearDeadline: () => {
clearDeadline(sync.deadlineMap); 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() { function isVotingClosed() {
@@ -85,7 +157,7 @@ export function initApp(container: HTMLElement): () => void {
<span>Polly</span> <span>Polly</span>
`; `;
const statusBar = StatusBar(sync.provider); const statusBar = StatusBar(sync.provider,userSync.provider,actions.onLoginLogout,actions.onCreateUser);
header.append(wordmark, statusBar); header.append(wordmark, statusBar);
// Main card // Main card
@@ -98,7 +170,7 @@ export function initApp(container: HTMLElement): () => void {
if (result && !result.ok) return result.error; if (result && !result.ok) return result.error;
return null; 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( const deadlineTimer = DeadlineTimer(
sync.deadlineMap, sync.deadlineMap,
actions.startDeadline, actions.startDeadline,

View File

@@ -2,11 +2,12 @@ import * as Y from "yjs";
import type { OptionRecord } from "../state"; import type { OptionRecord } from "../state";
import { PollOption } from "./PollOption"; import { PollOption } from "./PollOption";
import { enforceAppendOnly } from "../yDocUtil"; import { enforceAppendOnly } from "../yDocUtil";
import { User } from "../state";
export function PollList( export function PollList(
yOptions: Y.Map<OptionRecord>, yOptions: Y.Map<OptionRecord>,
yVotes: Y.Map<string>, yVotes: Y.Map<string>,
userId: string, user: User | undefined,
isVotingClosed: () => boolean, isVotingClosed: () => boolean,
onVote: (optionId: string) => void, onVote: (optionId: string) => void,
): HTMLElement { ): HTMLElement {
@@ -53,7 +54,10 @@ export function PollList(
tally.set(optionId, (tally.get(optionId) ?? 0) + 1); 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]) => { Object.entries(currentOptions).forEach(([id,record]) => {
console.log(`${record}: ${id}`) console.log(`${record}: ${id}`)

View File

@@ -1,30 +1,110 @@
import type { WebrtcProvider } from "y-webrtc"; import type { WebrtcProvider } from "y-webrtc";
export function StatusBar(provider: WebrtcProvider): HTMLElement { export function StatusBar(provider: WebrtcProvider, user_provider: WebrtcProvider, onLoginLogout: (event: Event) => Promise<boolean>, onCreateUser: (event: Event) => Promise<boolean>): HTMLElement {
const el = document.createElement("div"); const el = document.createElement("div");
el.className = "status-bar"; el.className = "status-bar";
const dot = document.createElement("span"); const statusPanel=document.createElement("div");
dot.className = "status-dot connecting"; statusPanel.className = "status-bar";
const statusText = document.createElement("span");
statusText.className = "status-text";
statusText.textContent = "Connecting";
const divider = document.createElement("span"); const divider = document.createElement("span");
divider.className = "status-divider"; divider.className = "status-divider";
divider.textContent = "\u00b7"; divider.textContent = "\u00b7";
const peerText = document.createElement("span"); function getProviderStatus(){
peerText.className = "status-peers";
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="<span>Logout</span>"
logoutButton.style.display = "none";
const createUserButton = document.createElement("button");
createUserButton.className = "add-option-btn";
createUserButton.setAttribute("aria-label", "Create User");
createUserButton.innerHTML="<span>Create User</span>"
async function onLoginLogoutResult(event: Event, loginLogout: (event: Event) => Promise<boolean>){
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 --- // --- Connection state ---
let syncTimeout: ReturnType<typeof setTimeout> | undefined = setTimeout(() => { let syncTimeout: ReturnType<typeof setTimeout> | undefined = setTimeout(() => {
statusText.textContent = "Ready"; pollProviderElements.statusText.textContent = "Ready";
dot.className = "status-dot ready"; pollProviderElements.dot.className = "status-dot ready";
}, 3000); }, 3000);
provider.on("synced", ({ synced }: { synced: boolean }) => { provider.on("synced", ({ synced }: { synced: boolean }) => {
@@ -32,18 +112,37 @@ export function StatusBar(provider: WebrtcProvider): HTMLElement {
clearTimeout(syncTimeout); clearTimeout(syncTimeout);
syncTimeout = undefined; syncTimeout = undefined;
} }
dot.className = `status-dot ${synced ? "connected" : "connecting"}`; pollProviderElements.dot.className = `status-dot ${synced ? "connected" : "connecting"}`;
statusText.textContent = synced ? "Connected" : "Connecting"; pollProviderElements.statusText.textContent = synced ? "Connected" : "Connecting";
});
let syncTimeout2: ReturnType<typeof setTimeout> | 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 // Online/offline awareness
const handleOffline = () => { const handleOffline = () => {
dot.className = "status-dot connecting"; pollProviderElements.dot.className = "status-dot connecting";
statusText.textContent = "Offline"; pollProviderElements.statusText.textContent = "Offline";
userProviderElements.dot.className = "status-dot connecting";
userProviderElements.statusText.textContent = "Offline";
}; };
const handleOnline = () => { const handleOnline = () => {
dot.className = "status-dot connecting"; pollProviderElements.dot.className = "status-dot connecting";
statusText.textContent = "Reconnecting"; pollProviderElements.statusText.textContent = "Reconnecting";
userProviderElements.dot.className = "status-dot connecting";
userProviderElements.statusText.textContent = "Reconnecting";
}; };
window.addEventListener("offline", handleOffline); window.addEventListener("offline", handleOffline);
@@ -54,10 +153,18 @@ export function StatusBar(provider: WebrtcProvider): HTMLElement {
function updatePeerCount() { function updatePeerCount() {
const total = provider.awareness.getStates().size; const total = provider.awareness.getStates().size;
const others = total - 1; const others = total - 1;
peerText.textContent = pollProviderElements.peerText.textContent =
others === 0 others === 0
? "Only you" ? "Only you"
: `${others} other${others !== 1 ? "s" : ""}`; : `${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); provider.awareness.on("change", updatePeerCount);

119
src/crypto.ts Normal file
View File

@@ -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<CryptoKey> => {
// 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<string> => {
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);
});
};

View File

@@ -4,6 +4,12 @@ import * as Y from "yjs";
export type ConnectionStatus = "connecting" | "connected" | "offline"; export type ConnectionStatus = "connecting" | "connected" | "offline";
export interface User{
userid: string,
private_key: CryptoKey,
public_key: CryptoKey | undefined,
}
export interface OptionRecord { export interface OptionRecord {
id: string; id: string;
label: string; label: string;

View File

@@ -86,6 +86,14 @@ button { cursor: pointer; }
font-size: 0.8rem; font-size: 0.8rem;
color: var(--text-secondary); 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 { .status-dot {
width: 6px; width: 6px;

70
src/userSync.ts Normal file
View File

@@ -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<string>;
provider: WebrtcProvider;
persistence: IndexeddbPersistence;
getConnectionStatus: () => ConnectionStatus;
getPeerCount: () => number;
destroy: () => void;
}
export function initUserSync(): UserSync {
const doc = new Y.Doc();
const users = doc.getMap<string>("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();
},
};
}