Made it more secure
All checks were successful
Docker Deploy / build-and-push (push) Successful in 1m38s

This commit is contained in:
Atridad Lahiji 2025-01-26 20:04:57 -06:00
parent f2720a3deb
commit 6dd3d1cdd3
Signed by: atridad
SSH key fingerprint: SHA256:LGomp8Opq0jz+7kbwNcdfTcuaLRb5Nh0k5AchDDb438
6 changed files with 496 additions and 116 deletions

View file

@ -1,4 +1,4 @@
import { useState } from "react"; import { useState, useEffect } from "react";
import { Settings } from "../../lib/settings"; import { Settings } from "../../lib/settings";
import Header from "../navigation/Header"; import Header from "../navigation/Header";
import LoginForm from "./LoginForm"; import LoginForm from "./LoginForm";
@ -7,20 +7,47 @@ import { RefreshContext } from "../../lib/RefreshContext";
import TabView from "../TabView"; import TabView from "../TabView";
export default function AuthWrapper() { export default function AuthWrapper() {
const [isConfigured, setIsConfigured] = useState( const [isConfigured, setIsConfigured] = useState(false);
Boolean(Settings.getServiceUrl() && Settings.getAdminPassword()), const [isLoading, setIsLoading] = useState(true);
);
const [lastUpdate, setLastUpdate] = useState(Date.now()); const [lastUpdate, setLastUpdate] = useState(Date.now());
const handleLogout = () => { useEffect(() => {
Settings.clearSettings(); 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); 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 = () => { const refresh = () => {
setLastUpdate(Date.now()); setLastUpdate(Date.now());
}; };
if (isLoading) {
return (
<div className="w-full h-screen flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900" />
</div>
);
}
if (!isConfigured) { if (!isConfigured) {
return <LoginForm onLogin={() => setIsConfigured(true)} />; return <LoginForm onLogin={() => setIsConfigured(true)} />;
} }

View file

@ -8,11 +8,26 @@ interface LoginFormProps {
export default function LoginForm({ onLogin }: LoginFormProps) { export default function LoginForm({ onLogin }: LoginFormProps) {
const [serviceUrl, setServiceUrl] = useState(""); const [serviceUrl, setServiceUrl] = useState("");
const [adminPassword, setAdminPassword] = useState(""); const [adminPassword, setAdminPassword] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
Settings.saveSettings(serviceUrl, adminPassword); setIsLoading(true);
setError(null);
try {
await Settings.saveSettings(serviceUrl, adminPassword);
onLogin(); onLogin();
} catch (err) {
setError(
err instanceof Error
? err.message
: "An error occurred while saving settings",
);
} finally {
setIsLoading(false);
}
}; };
return ( return (
@ -25,6 +40,26 @@ export default function LoginForm({ onLogin }: LoginFormProps) {
<p className="text-center text-base-content/70 mb-4"> <p className="text-center text-base-content/70 mb-4">
Enter your PDS credentials to get started Enter your PDS credentials to get started
</p> </p>
{error && (
<div className="alert alert-error shadow-lg mb-4">
<div>
<svg
xmlns="http://www.w3.org/2000/svg"
className="stroke-current flex-shrink-0 h-6 w-6"
fill="none"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>{error}</span>
</div>
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div className="form-control w-full"> <div className="form-control w-full">
<label className="label"> <label className="label">
@ -36,6 +71,7 @@ export default function LoginForm({ onLogin }: LoginFormProps) {
onChange={(e) => setServiceUrl(e.target.value)} onChange={(e) => setServiceUrl(e.target.value)}
placeholder="https://bsky.web.site" placeholder="https://bsky.web.site"
required required
disabled={isLoading}
className="input input-bordered w-full" className="input input-bordered w-full"
/> />
</div> </div>
@ -48,12 +84,24 @@ export default function LoginForm({ onLogin }: LoginFormProps) {
value={adminPassword} value={adminPassword}
onChange={(e) => setAdminPassword(e.target.value)} onChange={(e) => setAdminPassword(e.target.value)}
required required
disabled={isLoading}
className="input input-bordered w-full" className="input input-bordered w-full"
/> />
</div> </div>
<div className="divider"></div> <div className="divider"></div>
<button type="submit" className="btn btn-primary w-full"> <button
Connect to PDS type="submit"
className="btn btn-primary w-full"
disabled={isLoading}
>
{isLoading ? (
<>
<span className="loading loading-spinner"></span>
Connecting...
</>
) : (
"Connect to PDS"
)}
</button> </button>
</form> </form>
</div> </div>

View file

@ -17,7 +17,8 @@ interface InviteCode {
uses: InviteCodeUse[]; uses: InviteCodeUse[];
} }
function getAuthHeader(adminPassword: string) { async function getAuthHeader(): Promise<string> {
const adminPassword = await Settings.getAdminPassword();
const authString = `admin:${adminPassword}`; const authString = `admin:${adminPassword}`;
const base64Auth = btoa(authString); const base64Auth = btoa(authString);
return `Basic ${base64Auth}`; return `Basic ${base64Auth}`;
@ -48,9 +49,12 @@ export default function InviteCodes() {
setLoading(true); setLoading(true);
setError(null); 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 = { const headers = {
Authorization: getAuthHeader(Settings.getAdminPassword()), Authorization: authHeader,
"Content-Type": "application/json", "Content-Type": "application/json",
}; };
@ -86,9 +90,12 @@ export default function InviteCodes() {
setLoading(true); setLoading(true);
setError(null); 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 = { const headers = {
Authorization: getAuthHeader(Settings.getAdminPassword()), Authorization: authHeader,
"Content-Type": "application/json", "Content-Type": "application/json",
}; };
@ -122,9 +129,12 @@ export default function InviteCodes() {
setLoading(true); setLoading(true);
setError(null); 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 = { const headers = {
Authorization: getAuthHeader(Settings.getAdminPassword()), Authorization: authHeader,
"Content-Type": "application/json", "Content-Type": "application/json",
}; };
@ -179,7 +189,11 @@ export default function InviteCodes() {
disabled={loading} disabled={loading}
className="btn btn-primary" className="btn btn-primary"
> >
Create New Code {loading ? (
<span className="loading loading-spinner loading-sm"></span>
) : (
"Create New Code"
)}
</button> </button>
</div> </div>

View file

@ -24,16 +24,53 @@ interface RepoResponse {
}[]; }[];
} }
function getAuthHeader(adminPassword: string) { async function getAuthHeader(): Promise<string> {
const adminPassword = await Settings.getAdminPassword();
const authString = `admin:${adminPassword}`; const authString = `admin:${adminPassword}`;
const base64Auth = btoa(authString); const base64Auth = btoa(authString);
return `Basic ${base64Auth}`; return `Basic ${base64Auth}`;
} }
function UserAvatar({ user }: { user: User }) {
const [imageUrl, setImageUrl] = useState<string | null>(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 (
<div className="avatar placeholder">
<div className="bg-neutral-focus text-neutral-content rounded-full w-16">
<span className="text-xl">{user.displayName?.[0] || "?"}</span>
</div>
</div>
);
}
return (
<img
src={imageUrl}
alt={user.displayName || "User avatar"}
className="w-16 h-16 rounded-full object-cover"
/>
);
}
export default function UserList() { export default function UserList() {
const [users, setUsers] = useState<User[]>([]); const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [settingsLoaded, setSettingsLoaded] = useState(false);
const [hasValidSettings, setHasValidSettings] = useState(false);
const { lastUpdate } = useRefresh(); const { lastUpdate } = useRefresh();
const [deleteConfirmation, setDeleteConfirmation] = useState<{ const [deleteConfirmation, setDeleteConfirmation] = useState<{
show: boolean; show: boolean;
@ -51,14 +88,11 @@ export default function UserList() {
const fetchUserProfile = async (did: string): Promise<User | null> => { const fetchUserProfile = async (did: string): Promise<User | null> => {
try { try {
const baseUrl = Settings.getServiceUrl(); const baseUrl = await Settings.getServiceUrl();
if (!baseUrl) throw new Error("Service URL not configured"); if (!baseUrl) throw new Error("Service URL not configured");
const adminPassword = Settings.getAdminPassword();
if (!adminPassword) throw new Error("Admin password not configured");
const headers = { const headers = {
Authorization: getAuthHeader(adminPassword), Authorization: await getAuthHeader(),
"Content-Type": "application/json", "Content-Type": "application/json",
}; };
@ -90,14 +124,11 @@ export default function UserList() {
setLoading(true); setLoading(true);
setError(null); setError(null);
const baseUrl = Settings.getServiceUrl(); const baseUrl = await Settings.getServiceUrl();
if (!baseUrl) throw new Error("Service URL not configured"); if (!baseUrl) throw new Error("Service URL not configured");
const adminPassword = Settings.getAdminPassword();
if (!adminPassword) throw new Error("Admin password not configured");
const headers = { const headers = {
Authorization: getAuthHeader(adminPassword), Authorization: await getAuthHeader(),
"Content-Type": "application/json", "Content-Type": "application/json",
}; };
@ -126,20 +157,16 @@ export default function UserList() {
setLoading(false); setLoading(false);
} }
}; };
const fetchUsers = async () => { const fetchUsers = async () => {
try { try {
setLoading(true); setLoading(true);
setError(null); setError(null);
const baseUrl = Settings.getServiceUrl(); const baseUrl = await Settings.getServiceUrl();
if (!baseUrl) throw new Error("Service URL not configured"); if (!baseUrl) throw new Error("Service URL not configured");
const adminPassword = Settings.getAdminPassword();
if (!adminPassword) throw new Error("Admin password not configured");
const headers = { const headers = {
Authorization: getAuthHeader(adminPassword), Authorization: await getAuthHeader(),
"Content-Type": "application/json", "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(() => { useEffect(() => {
return () => { return () => {
resetState(); resetState();
}; };
}, []); }, []);
useEffect(() => {
if (typeof window !== "undefined") {
fetchUsers();
}
}, [lastUpdate]);
if (typeof window === "undefined") { if (typeof window === "undefined") {
return <div></div>; return <div></div>;
} }
const serviceUrl = Settings.getServiceUrl(); if (!settingsLoaded) {
const adminPassword = Settings.getAdminPassword(); return (
<div className="card bg-base-100 shadow-xl">
<div className="card-body">
<div className="flex justify-center p-8">
<span className="loading loading-spinner loading-lg"></span>
</div>
</div>
</div>
);
}
if (!serviceUrl || !adminPassword) { if (!hasValidSettings) {
return ( return (
<div className="card bg-base-100 shadow-xl"> <div className="card bg-base-100 shadow-xl">
<div className="card-body"> <div className="card-body">
@ -238,11 +292,15 @@ export default function UserList() {
<div className="flex justify-between items-center mb-4"> <div className="flex justify-between items-center mb-4">
<h1 className="card-title text-2xl">BlueSky Users</h1> <h1 className="card-title text-2xl">BlueSky Users</h1>
<button <button
onClick={fetchUsers} onClick={() => fetchUsers()}
disabled={loading} disabled={loading}
className="btn btn-primary" className="btn btn-primary"
> >
Refresh Users {loading ? (
<span className="loading loading-spinner loading-sm"></span>
) : (
"Refresh Users"
)}
</button> </button>
</div> </div>
@ -314,7 +372,7 @@ export default function UserList() {
<h3 className="font-bold">Error!</h3> <h3 className="font-bold">Error!</h3>
<div className="text-xs">{error}</div> <div className="text-xs">{error}</div>
</div> </div>
<button onClick={fetchUsers} className="btn btn-sm"> <button onClick={() => fetchUsers()} className="btn btn-sm">
Retry Retry
</button> </button>
</div> </div>
@ -333,23 +391,7 @@ export default function UserList() {
> >
<div className="card-body"> <div className="card-body">
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
{user.avatar ? ( <UserAvatar user={user} />
<img
src={`${serviceUrl}/xrpc/com.atproto.sync.getBlob?did=${
user.did
}&cid=${user.avatar.ref.$link}`}
alt={user.displayName || "User avatar"}
className="w-16 h-16 rounded-full object-cover"
/>
) : (
<div className="avatar placeholder">
<div className="bg-neutral-focus text-neutral-content rounded-full w-16">
<span className="text-xl">
{user.displayName?.[0] || "?"}
</span>
</div>
</div>
)}
<div className="flex-1"> <div className="flex-1">
<h2 className="text-lg font-semibold"> <h2 className="text-lg font-semibold">
{user.displayName || "Anonymous"} {user.displayName || "Anonymous"}

View file

@ -1,19 +1,53 @@
import { useLogout } from "../../lib/LogoutContext"; import { useLogout } from "../../lib/LogoutContext";
import { useRefresh } from "../../lib/RefreshContext"; import { useRefresh } from "../../lib/RefreshContext";
import { useState } from "react"; import { useState, useEffect } from "react";
import { Settings } from "../../lib/settings"; import { Settings } from "../../lib/settings";
export default function NavBar() { export default function NavBar() {
const logout = useLogout(); const logout = useLogout();
const { refresh } = useRefresh(); const { refresh } = useRefresh();
const [showSettings, setShowSettings] = useState(false); const [showSettings, setShowSettings] = useState(false);
const [newUrl, setNewUrl] = useState(Settings.getServiceUrl()); const [newUrl, setNewUrl] = useState("");
const [newPassword, setNewPassword] = useState(Settings.getAdminPassword()); const [newPassword, setNewPassword] = useState("");
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const saveSettings = () => { useEffect(() => {
Settings.saveSettings(newUrl, newPassword); 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); setShowSettings(false);
refresh(); refresh();
} catch (err) {
console.error("Error saving settings:", err);
setError("Failed to save settings");
} finally {
setIsSaving(false);
}
}; };
return ( return (
@ -78,6 +112,30 @@ export default function NavBar() {
<dialog className={`modal ${showSettings ? "modal-open" : ""}`}> <dialog className={`modal ${showSettings ? "modal-open" : ""}`}>
<div className="modal-box"> <div className="modal-box">
<h3 className="font-bold text-lg mb-4">PDS Settings</h3> <h3 className="font-bold text-lg mb-4">PDS Settings</h3>
{error && (
<div className="alert alert-error mb-4">
<svg
xmlns="http://www.w3.org/2000/svg"
className="stroke-current shrink-0 h-6 w-6"
fill="none"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>{error}</span>
</div>
)}
{isLoading ? (
<div className="flex justify-center p-8">
<span className="loading loading-spinner loading-lg"></span>
</div>
) : (
<>
<div className="form-control w-full"> <div className="form-control w-full">
<label className="label"> <label className="label">
<span className="label-text">PDS URL</span> <span className="label-text">PDS URL</span>
@ -88,6 +146,7 @@ export default function NavBar() {
onChange={(e) => setNewUrl(e.target.value)} onChange={(e) => setNewUrl(e.target.value)}
className="input input-bordered w-full" className="input input-bordered w-full"
placeholder="https://bsky.atri.dad" placeholder="https://bsky.atri.dad"
disabled={isSaving}
/> />
</div> </div>
<div className="form-control w-full mt-4"> <div className="form-control w-full mt-4">
@ -99,16 +158,34 @@ export default function NavBar() {
value={newPassword} value={newPassword}
onChange={(e) => setNewPassword(e.target.value)} onChange={(e) => setNewPassword(e.target.value)}
className="input input-bordered w-full" className="input input-bordered w-full"
disabled={isSaving}
/> />
</div> </div>
<div className="modal-action"> <div className="modal-action">
<button onClick={() => setShowSettings(false)} className="btn"> <button
onClick={() => setShowSettings(false)}
className="btn"
disabled={isSaving}
>
Cancel Cancel
</button> </button>
<button onClick={saveSettings} className="btn btn-primary"> <button
Save onClick={saveSettings}
className="btn btn-primary"
disabled={isSaving}
>
{isSaving ? (
<>
<span className="loading loading-spinner loading-sm"></span>
Saving...
</>
) : (
"Save"
)}
</button> </button>
</div> </div>
</>
)}
</div> </div>
<form method="dialog" className="modal-backdrop"> <form method="dialog" className="modal-backdrop">
<button onClick={() => setShowSettings(false)}>close</button> <button onClick={() => setShowSettings(false)}>close</button>

View file

@ -1,23 +1,195 @@
export class Settings { export class Settings {
static getServiceUrl(): string { private static readonly ENCRYPTION_KEY_NAME =
if (typeof window === "undefined") return ""; "pds-manager-settings-encryption-key";
return window.localStorage.getItem("pds_url") || ""; 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<CryptoKey> {
const db = await this.getDB();
const transaction = db.transaction(this.STORE_NAME, "readwrite");
const store = transaction.objectStore(this.STORE_NAME);
return new Promise<CryptoKey>(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<void>((res, rej) => {
newTransaction.oncomplete = () => res();
newTransaction.onerror = () => rej(newTransaction.error);
});
resolve(key);
}
};
request.onerror = () => reject(request.error);
} catch (error) {
reject(error);
}
});
} }
static getAdminPassword(): string { private static async encrypt(data: string): Promise<ArrayBuffer> {
if (typeof window === "undefined") return ""; const key = await this.getOrCreateEncryptionKey();
return window.localStorage.getItem("pds_admin_password") || ""; 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<string> {
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<IDBDatabase> {
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<void> {
if (typeof window === "undefined") return; 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<string> {
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<string> {
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<void> {
if (typeof window === "undefined") return; 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);
});
} }
} }