diff --git a/src/components/auth/AuthWrapper.tsx b/src/components/auth/AuthWrapper.tsx index b891ed7..bd22795 100644 --- a/src/components/auth/AuthWrapper.tsx +++ b/src/components/auth/AuthWrapper.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, useEffect } from "react"; import { Settings } from "../../lib/settings"; import Header from "../navigation/Header"; import LoginForm from "./LoginForm"; @@ -7,20 +7,47 @@ import { RefreshContext } from "../../lib/RefreshContext"; import TabView from "../TabView"; export default function AuthWrapper() { - const [isConfigured, setIsConfigured] = useState( - Boolean(Settings.getServiceUrl() && Settings.getAdminPassword()), - ); + const [isConfigured, setIsConfigured] = useState(false); + const [isLoading, setIsLoading] = useState(true); const [lastUpdate, setLastUpdate] = useState(Date.now()); - const handleLogout = () => { - Settings.clearSettings(); - setIsConfigured(false); + useEffect(() => { + async function checkConfiguration() { + try { + const serviceUrl = await Settings.getServiceUrl(); + const adminPassword = await Settings.getAdminPassword(); + setIsConfigured(Boolean(serviceUrl && adminPassword)); + } catch (error) { + console.error("Error checking configuration:", error); + setIsConfigured(false); + } finally { + setIsLoading(false); + } + } + checkConfiguration(); + }, [lastUpdate]); + + const handleLogout = async () => { + try { + await Settings.clearSettings(); + setIsConfigured(false); + } catch (error) { + console.error("Error during logout:", error); + } }; const refresh = () => { setLastUpdate(Date.now()); }; + if (isLoading) { + return ( +
+
+
+ ); + } + if (!isConfigured) { return setIsConfigured(true)} />; } diff --git a/src/components/auth/LoginForm.tsx b/src/components/auth/LoginForm.tsx index 1248a3e..bfaf2b4 100644 --- a/src/components/auth/LoginForm.tsx +++ b/src/components/auth/LoginForm.tsx @@ -8,11 +8,26 @@ interface LoginFormProps { export default function LoginForm({ onLogin }: LoginFormProps) { const [serviceUrl, setServiceUrl] = useState(""); const [adminPassword, setAdminPassword] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); - const handleSubmit = (e: React.FormEvent) => { + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); - Settings.saveSettings(serviceUrl, adminPassword); - onLogin(); + setIsLoading(true); + setError(null); + + try { + await Settings.saveSettings(serviceUrl, adminPassword); + onLogin(); + } catch (err) { + setError( + err instanceof Error + ? err.message + : "An error occurred while saving settings", + ); + } finally { + setIsLoading(false); + } }; return ( @@ -25,6 +40,26 @@ export default function LoginForm({ onLogin }: LoginFormProps) {

Enter your PDS credentials to get started

+ {error && ( +
+
+ + + + {error} +
+
+ )}
@@ -48,12 +84,24 @@ export default function LoginForm({ onLogin }: LoginFormProps) { value={adminPassword} onChange={(e) => setAdminPassword(e.target.value)} required + disabled={isLoading} className="input input-bordered w-full" />
- diff --git a/src/components/features/invites/InviteCodes.tsx b/src/components/features/invites/InviteCodes.tsx index ea4583e..f83e5d2 100644 --- a/src/components/features/invites/InviteCodes.tsx +++ b/src/components/features/invites/InviteCodes.tsx @@ -17,7 +17,8 @@ interface InviteCode { uses: InviteCodeUse[]; } -function getAuthHeader(adminPassword: string) { +async function getAuthHeader(): Promise { + const adminPassword = await Settings.getAdminPassword(); const authString = `admin:${adminPassword}`; const base64Auth = btoa(authString); return `Basic ${base64Auth}`; @@ -48,9 +49,12 @@ export default function InviteCodes() { setLoading(true); setError(null); - const url = `${Settings.getServiceUrl()}/xrpc/com.atproto.admin.getInviteCodes`; + const serviceUrl = await Settings.getServiceUrl(); + const authHeader = await getAuthHeader(); + + const url = `${serviceUrl}/xrpc/com.atproto.admin.getInviteCodes`; const headers = { - Authorization: getAuthHeader(Settings.getAdminPassword()), + Authorization: authHeader, "Content-Type": "application/json", }; @@ -86,9 +90,12 @@ export default function InviteCodes() { setLoading(true); setError(null); - const url = `${Settings.getServiceUrl()}/xrpc/com.atproto.server.createInviteCode`; + const serviceUrl = await Settings.getServiceUrl(); + const authHeader = await getAuthHeader(); + + const url = `${serviceUrl}/xrpc/com.atproto.server.createInviteCode`; const headers = { - Authorization: getAuthHeader(Settings.getAdminPassword()), + Authorization: authHeader, "Content-Type": "application/json", }; @@ -122,9 +129,12 @@ export default function InviteCodes() { setLoading(true); setError(null); - const url = `${Settings.getServiceUrl()}/xrpc/com.atproto.admin.disableInviteCodes`; + const serviceUrl = await Settings.getServiceUrl(); + const authHeader = await getAuthHeader(); + + const url = `${serviceUrl}/xrpc/com.atproto.admin.disableInviteCodes`; const headers = { - Authorization: getAuthHeader(Settings.getAdminPassword()), + Authorization: authHeader, "Content-Type": "application/json", }; @@ -179,7 +189,11 @@ export default function InviteCodes() { disabled={loading} className="btn btn-primary" > - Create New Code + {loading ? ( + + ) : ( + "Create New Code" + )} diff --git a/src/components/features/users/UserList.tsx b/src/components/features/users/UserList.tsx index fb6f73f..b06309b 100644 --- a/src/components/features/users/UserList.tsx +++ b/src/components/features/users/UserList.tsx @@ -24,16 +24,53 @@ interface RepoResponse { }[]; } -function getAuthHeader(adminPassword: string) { +async function getAuthHeader(): Promise { + const adminPassword = await Settings.getAdminPassword(); const authString = `admin:${adminPassword}`; const base64Auth = btoa(authString); return `Basic ${base64Auth}`; } +function UserAvatar({ user }: { user: User }) { + const [imageUrl, setImageUrl] = useState(null); + + useEffect(() => { + async function loadImageUrl() { + if (user.avatar) { + const serviceUrl = await Settings.getServiceUrl(); + setImageUrl( + `${serviceUrl}/xrpc/com.atproto.sync.getBlob?did=${user.did}&cid=${user.avatar.ref.$link}`, + ); + } + } + loadImageUrl(); + }, [user]); + + if (!user.avatar || !imageUrl) { + return ( +
+
+ {user.displayName?.[0] || "?"} +
+
+ ); + } + + return ( + {user.displayName + ); +} + export default function UserList() { const [users, setUsers] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const [settingsLoaded, setSettingsLoaded] = useState(false); + const [hasValidSettings, setHasValidSettings] = useState(false); const { lastUpdate } = useRefresh(); const [deleteConfirmation, setDeleteConfirmation] = useState<{ show: boolean; @@ -51,14 +88,11 @@ export default function UserList() { const fetchUserProfile = async (did: string): Promise => { try { - const baseUrl = Settings.getServiceUrl(); + const baseUrl = await Settings.getServiceUrl(); if (!baseUrl) throw new Error("Service URL not configured"); - const adminPassword = Settings.getAdminPassword(); - if (!adminPassword) throw new Error("Admin password not configured"); - const headers = { - Authorization: getAuthHeader(adminPassword), + Authorization: await getAuthHeader(), "Content-Type": "application/json", }; @@ -90,14 +124,11 @@ export default function UserList() { setLoading(true); setError(null); - const baseUrl = Settings.getServiceUrl(); + const baseUrl = await Settings.getServiceUrl(); if (!baseUrl) throw new Error("Service URL not configured"); - const adminPassword = Settings.getAdminPassword(); - if (!adminPassword) throw new Error("Admin password not configured"); - const headers = { - Authorization: getAuthHeader(adminPassword), + Authorization: await getAuthHeader(), "Content-Type": "application/json", }; @@ -126,20 +157,16 @@ export default function UserList() { setLoading(false); } }; - const fetchUsers = async () => { try { setLoading(true); setError(null); - const baseUrl = Settings.getServiceUrl(); + const baseUrl = await Settings.getServiceUrl(); if (!baseUrl) throw new Error("Service URL not configured"); - const adminPassword = Settings.getAdminPassword(); - if (!adminPassword) throw new Error("Admin password not configured"); - const headers = { - Authorization: getAuthHeader(adminPassword), + Authorization: await getAuthHeader(), "Content-Type": "application/json", }; @@ -183,26 +210,53 @@ export default function UserList() { } }; + useEffect(() => { + async function checkSettings() { + try { + const serviceUrl = await Settings.getServiceUrl(); + const adminPassword = await Settings.getAdminPassword(); + setHasValidSettings(Boolean(serviceUrl && adminPassword)); + } catch (error) { + setHasValidSettings(false); + } finally { + setSettingsLoaded(true); + } + } + + if (typeof window !== "undefined") { + checkSettings(); + } + }, [lastUpdate]); + + useEffect(() => { + if (typeof window !== "undefined" && hasValidSettings) { + fetchUsers(); + } + }, [lastUpdate, hasValidSettings]); + useEffect(() => { return () => { resetState(); }; }, []); - useEffect(() => { - if (typeof window !== "undefined") { - fetchUsers(); - } - }, [lastUpdate]); - if (typeof window === "undefined") { return
; } - const serviceUrl = Settings.getServiceUrl(); - const adminPassword = Settings.getAdminPassword(); + if (!settingsLoaded) { + return ( +
+
+
+ +
+
+
+ ); + } - if (!serviceUrl || !adminPassword) { + if (!hasValidSettings) { return (
@@ -238,11 +292,15 @@ export default function UserList() {

BlueSky Users

@@ -314,7 +372,7 @@ export default function UserList() {

Error!

{error}
-
@@ -333,23 +391,7 @@ export default function UserList() { >
- {user.avatar ? ( - {user.displayName - ) : ( -
-
- - {user.displayName?.[0] || "?"} - -
-
- )} +

{user.displayName || "Anonymous"} diff --git a/src/components/navigation/Header.tsx b/src/components/navigation/Header.tsx index b41773f..2e0d972 100644 --- a/src/components/navigation/Header.tsx +++ b/src/components/navigation/Header.tsx @@ -1,19 +1,53 @@ import { useLogout } from "../../lib/LogoutContext"; import { useRefresh } from "../../lib/RefreshContext"; -import { useState } from "react"; +import { useState, useEffect } from "react"; import { Settings } from "../../lib/settings"; export default function NavBar() { const logout = useLogout(); const { refresh } = useRefresh(); const [showSettings, setShowSettings] = useState(false); - const [newUrl, setNewUrl] = useState(Settings.getServiceUrl()); - const [newPassword, setNewPassword] = useState(Settings.getAdminPassword()); + const [newUrl, setNewUrl] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + const [error, setError] = useState(null); - const saveSettings = () => { - Settings.saveSettings(newUrl, newPassword); - setShowSettings(false); - refresh(); + useEffect(() => { + async function loadSettings() { + try { + const [url, password] = await Promise.all([ + Settings.getServiceUrl(), + Settings.getAdminPassword(), + ]); + setNewUrl(url); + setNewPassword(password); + } catch (err) { + console.error("Error loading settings:", err); + setError("Failed to load settings"); + } finally { + setIsLoading(false); + } + } + + if (showSettings) { + loadSettings(); + } + }, [showSettings]); + + const saveSettings = async () => { + try { + setIsSaving(true); + setError(null); + await Settings.saveSettings(newUrl, newPassword); + setShowSettings(false); + refresh(); + } catch (err) { + console.error("Error saving settings:", err); + setError("Failed to save settings"); + } finally { + setIsSaving(false); + } }; return ( @@ -78,37 +112,80 @@ export default function NavBar() {

PDS Settings

-
- - setNewUrl(e.target.value)} - className="input input-bordered w-full" - placeholder="https://bsky.atri.dad" - /> -
-
- - setNewPassword(e.target.value)} - className="input input-bordered w-full" - /> -
-
- - -
+ {error && ( +
+ + + + {error} +
+ )} + {isLoading ? ( +
+ +
+ ) : ( + <> +
+ + setNewUrl(e.target.value)} + className="input input-bordered w-full" + placeholder="https://bsky.atri.dad" + disabled={isSaving} + /> +
+
+ + setNewPassword(e.target.value)} + className="input input-bordered w-full" + disabled={isSaving} + /> +
+
+ + +
+ + )}
diff --git a/src/lib/settings.ts b/src/lib/settings.ts index 7316fbb..ee0b050 100644 --- a/src/lib/settings.ts +++ b/src/lib/settings.ts @@ -1,23 +1,195 @@ export class Settings { - static getServiceUrl(): string { - if (typeof window === "undefined") return ""; - return window.localStorage.getItem("pds_url") || ""; + private static readonly ENCRYPTION_KEY_NAME = + "pds-manager-settings-encryption-key"; + private static readonly IV_LENGTH = 12; + private static readonly DB_NAME = "pds-manager-settings"; + private static readonly STORE_NAME = "settings"; + + private static async getOrCreateEncryptionKey(): Promise { + const db = await this.getDB(); + const transaction = db.transaction(this.STORE_NAME, "readwrite"); + const store = transaction.objectStore(this.STORE_NAME); + + return new Promise(async (resolve, reject) => { + try { + const request = store.get(this.ENCRYPTION_KEY_NAME); + + request.onsuccess = async () => { + if (request.result) { + const keyBuffer = request.result; + const key = await window.crypto.subtle.importKey( + "raw", + keyBuffer, + "AES-GCM", + true, + ["encrypt", "decrypt"], + ); + resolve(key); + } else { + // Generate new key if none exists + const key = await window.crypto.subtle.generateKey( + { + name: "AES-GCM", + length: 256, + }, + true, + ["encrypt", "decrypt"], + ); + + // Export and store the key + const exportedKey = await window.crypto.subtle.exportKey( + "raw", + key, + ); + + // Create a new transaction for storing the key + const newTransaction = db.transaction(this.STORE_NAME, "readwrite"); + const newStore = newTransaction.objectStore(this.STORE_NAME); + newStore.put(exportedKey, this.ENCRYPTION_KEY_NAME); + + await new Promise((res, rej) => { + newTransaction.oncomplete = () => res(); + newTransaction.onerror = () => rej(newTransaction.error); + }); + + resolve(key); + } + }; + + request.onerror = () => reject(request.error); + } catch (error) { + reject(error); + } + }); } - static getAdminPassword(): string { - if (typeof window === "undefined") return ""; - return window.localStorage.getItem("pds_admin_password") || ""; + private static async encrypt(data: string): Promise { + const key = await this.getOrCreateEncryptionKey(); + const iv = window.crypto.getRandomValues(new Uint8Array(this.IV_LENGTH)); + const encoded = new TextEncoder().encode(data); + + const ciphertext = await window.crypto.subtle.encrypt( + { + name: "AES-GCM", + iv, + }, + key, + encoded, + ); + + const resultBuffer = new ArrayBuffer(iv.length + ciphertext.byteLength); + const resultArray = new Uint8Array(resultBuffer); + resultArray.set(iv); + resultArray.set(new Uint8Array(ciphertext), iv.length); + + return resultBuffer; } - static saveSettings(url: string, password: string) { + private static async decrypt(data: ArrayBuffer): Promise { + const key = await this.getOrCreateEncryptionKey(); + const iv = new Uint8Array(data, 0, this.IV_LENGTH); + const ciphertext = new Uint8Array(data, this.IV_LENGTH); + + const decrypted = await window.crypto.subtle.decrypt( + { + name: "AES-GCM", + iv, + }, + key, + ciphertext, + ); + + return new TextDecoder().decode(decrypted); + } + + private static async getDB(): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(this.DB_NAME, 1); + + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(request.result); + + request.onupgradeneeded = (event) => { + const db = (event.target as IDBOpenDBRequest).result; + db.createObjectStore(this.STORE_NAME); + }; + }); + } + + static async saveSettings(url: string, password: string): Promise { if (typeof window === "undefined") return; - window.localStorage.setItem("pds_url", url); - window.localStorage.setItem("pds_admin_password", password); + + const encryptedUrl = await this.encrypt(url); + const encryptedPassword = await this.encrypt(password); + + const db = await this.getDB(); + const transaction = db.transaction(this.STORE_NAME, "readwrite"); + const store = transaction.objectStore(this.STORE_NAME); + + store.put(encryptedUrl, "pds_url"); + store.put(encryptedPassword, "pds_admin_password"); + + return new Promise((resolve, reject) => { + transaction.oncomplete = () => resolve(); + transaction.onerror = () => reject(transaction.error); + }); } - static clearSettings() { + static async getServiceUrl(): Promise { + if (typeof window === "undefined") return ""; + + const db = await this.getDB(); + const transaction = db.transaction(this.STORE_NAME, "readonly"); + const store = transaction.objectStore(this.STORE_NAME); + + return new Promise((resolve, reject) => { + const request = store.get("pds_url"); + request.onsuccess = async () => { + if (!request.result) { + resolve(""); + return; + } + const decrypted = await this.decrypt(request.result); + resolve(decrypted); + }; + request.onerror = () => reject(request.error); + }); + } + + static async getAdminPassword(): Promise { + if (typeof window === "undefined") return ""; + + const db = await this.getDB(); + const transaction = db.transaction(this.STORE_NAME, "readonly"); + const store = transaction.objectStore(this.STORE_NAME); + + return new Promise((resolve, reject) => { + const request = store.get("pds_admin_password"); + request.onsuccess = async () => { + if (!request.result) { + resolve(""); + return; + } + const decrypted = await this.decrypt(request.result); + resolve(decrypted); + }; + request.onerror = () => reject(request.error); + }); + } + + static async clearSettings(): Promise { if (typeof window === "undefined") return; - window.localStorage.removeItem("pds_url"); - window.localStorage.removeItem("pds_admin_password"); + + const db = await this.getDB(); + const transaction = db.transaction(this.STORE_NAME, "readwrite"); + const store = transaction.objectStore(this.STORE_NAME); + + store.delete("pds_url"); + store.delete("pds_admin_password"); + + return new Promise((resolve, reject) => { + transaction.oncomplete = () => resolve(); + transaction.onerror = () => reject(transaction.error); + }); } }