Made it more secure
All checks were successful
Docker Deploy / build-and-push (push) Successful in 1m38s
All checks were successful
Docker Deploy / build-and-push (push) Successful in 1m38s
This commit is contained in:
parent
f2720a3deb
commit
6dd3d1cdd3
6 changed files with 496 additions and 116 deletions
|
@ -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 (
|
||||
<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) {
|
||||
return <LoginForm onLogin={() => setIsConfigured(true)} />;
|
||||
}
|
||||
|
|
|
@ -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<string | null>(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) {
|
|||
<p className="text-center text-base-content/70 mb-4">
|
||||
Enter your PDS credentials to get started
|
||||
</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">
|
||||
<div className="form-control w-full">
|
||||
<label className="label">
|
||||
|
@ -36,6 +71,7 @@ export default function LoginForm({ onLogin }: LoginFormProps) {
|
|||
onChange={(e) => setServiceUrl(e.target.value)}
|
||||
placeholder="https://bsky.web.site"
|
||||
required
|
||||
disabled={isLoading}
|
||||
className="input input-bordered w-full"
|
||||
/>
|
||||
</div>
|
||||
|
@ -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"
|
||||
/>
|
||||
</div>
|
||||
<div className="divider"></div>
|
||||
<button type="submit" className="btn btn-primary w-full">
|
||||
Connect to PDS
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary w-full"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<span className="loading loading-spinner"></span>
|
||||
Connecting...
|
||||
</>
|
||||
) : (
|
||||
"Connect to PDS"
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
|
|
@ -17,7 +17,8 @@ interface InviteCode {
|
|||
uses: InviteCodeUse[];
|
||||
}
|
||||
|
||||
function getAuthHeader(adminPassword: string) {
|
||||
async function getAuthHeader(): Promise<string> {
|
||||
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 ? (
|
||||
<span className="loading loading-spinner loading-sm"></span>
|
||||
) : (
|
||||
"Create New Code"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
|
|
@ -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 base64Auth = btoa(authString);
|
||||
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() {
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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<User | null> => {
|
||||
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 <div></div>;
|
||||
}
|
||||
|
||||
const serviceUrl = Settings.getServiceUrl();
|
||||
const adminPassword = Settings.getAdminPassword();
|
||||
if (!settingsLoaded) {
|
||||
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 (
|
||||
<div className="card bg-base-100 shadow-xl">
|
||||
<div className="card-body">
|
||||
|
@ -238,11 +292,15 @@ export default function UserList() {
|
|||
<div className="flex justify-between items-center mb-4">
|
||||
<h1 className="card-title text-2xl">BlueSky Users</h1>
|
||||
<button
|
||||
onClick={fetchUsers}
|
||||
onClick={() => fetchUsers()}
|
||||
disabled={loading}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
Refresh Users
|
||||
{loading ? (
|
||||
<span className="loading loading-spinner loading-sm"></span>
|
||||
) : (
|
||||
"Refresh Users"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
@ -314,7 +372,7 @@ export default function UserList() {
|
|||
<h3 className="font-bold">Error!</h3>
|
||||
<div className="text-xs">{error}</div>
|
||||
</div>
|
||||
<button onClick={fetchUsers} className="btn btn-sm">
|
||||
<button onClick={() => fetchUsers()} className="btn btn-sm">
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
|
@ -333,23 +391,7 @@ export default function UserList() {
|
|||
>
|
||||
<div className="card-body">
|
||||
<div className="flex items-start gap-4">
|
||||
{user.avatar ? (
|
||||
<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>
|
||||
)}
|
||||
<UserAvatar user={user} />
|
||||
<div className="flex-1">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{user.displayName || "Anonymous"}
|
||||
|
|
|
@ -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<string | null>(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() {
|
|||
<dialog className={`modal ${showSettings ? "modal-open" : ""}`}>
|
||||
<div className="modal-box">
|
||||
<h3 className="font-bold text-lg mb-4">PDS Settings</h3>
|
||||
<div className="form-control w-full">
|
||||
<label className="label">
|
||||
<span className="label-text">PDS URL</span>
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
value={newUrl}
|
||||
onChange={(e) => setNewUrl(e.target.value)}
|
||||
className="input input-bordered w-full"
|
||||
placeholder="https://bsky.atri.dad"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-control w-full mt-4">
|
||||
<label className="label">
|
||||
<span className="label-text">Admin Password</span>
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
className="input input-bordered w-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="modal-action">
|
||||
<button onClick={() => setShowSettings(false)} className="btn">
|
||||
Cancel
|
||||
</button>
|
||||
<button onClick={saveSettings} className="btn btn-primary">
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
{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">
|
||||
<label className="label">
|
||||
<span className="label-text">PDS URL</span>
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
value={newUrl}
|
||||
onChange={(e) => setNewUrl(e.target.value)}
|
||||
className="input input-bordered w-full"
|
||||
placeholder="https://bsky.atri.dad"
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-control w-full mt-4">
|
||||
<label className="label">
|
||||
<span className="label-text">Admin Password</span>
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
className="input input-bordered w-full"
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</div>
|
||||
<div className="modal-action">
|
||||
<button
|
||||
onClick={() => setShowSettings(false)}
|
||||
className="btn"
|
||||
disabled={isSaving}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={saveSettings}
|
||||
className="btn btn-primary"
|
||||
disabled={isSaving}
|
||||
>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<span className="loading loading-spinner loading-sm"></span>
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
"Save"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<form method="dialog" className="modal-backdrop">
|
||||
<button onClick={() => setShowSettings(false)}>close</button>
|
||||
|
|
|
@ -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<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 {
|
||||
if (typeof window === "undefined") return "";
|
||||
return window.localStorage.getItem("pds_admin_password") || "";
|
||||
private static async encrypt(data: string): Promise<ArrayBuffer> {
|
||||
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<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;
|
||||
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;
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue