+ create user, login, logout
This commit is contained in:
14
package-lock.json
generated
14
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
86
src/app.ts
86
src/app.ts
@@ -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,
|
||||||
|
|||||||
@@ -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}`)
|
||||||
|
|||||||
@@ -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
119
src/crypto.ts
Normal 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);
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
70
src/userSync.ts
Normal 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();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user