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 { 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)} />;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
@ -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"}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Reference in a new issue