0.3.0
All checks were successful
Docker Deploy / build-and-push (push) Successful in 57s

This commit is contained in:
Atridad Lahiji 2025-01-27 01:30:52 -06:00
parent 5a4079bc1f
commit 622bf8eb0d
Signed by: atridad
SSH key fingerprint: SHA256:LGomp8Opq0jz+7kbwNcdfTcuaLRb5Nh0k5AchDDb438
3 changed files with 268 additions and 29 deletions

View file

@ -1,7 +1,7 @@
{ {
"name": "pdsmanager", "name": "pdsmanager",
"type": "module", "type": "module",
"version": "0.2.2", "version": "0.3.0",
"scripts": { "scripts": {
"dev": "astro dev", "dev": "astro dev",
"build": "astro build", "build": "astro build",

View file

@ -28,6 +28,8 @@ export default function InviteCodes() {
const [codes, setCodes] = useState<InviteCode[]>([]); const [codes, setCodes] = useState<InviteCode[]>([]);
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 [showDisabled, setShowDisabled] = useState(false);
const { lastUpdate } = useRefresh(); const { lastUpdate } = useRefresh();
const resetState = () => { const resetState = () => {
@ -36,8 +38,10 @@ export default function InviteCodes() {
setLoading(false); setLoading(false);
}; };
const sortCodes = (codes: InviteCode[]) => { const filterAndSortCodes = (codes: InviteCode[]) => {
return [...codes].sort((a, b) => { return [...codes]
.filter((code) => showDisabled || !code.disabled)
.sort((a, b) => {
if (a.disabled && !b.disabled) return 1; if (a.disabled && !b.disabled) return 1;
if (!a.disabled && b.disabled) return -1; if (!a.disabled && b.disabled) return -1;
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
@ -183,7 +187,18 @@ export default function InviteCodes() {
<div className="card bg-base-100 shadow-xl"> <div className="card bg-base-100 shadow-xl">
<div className="card-body"> <div className="card-body">
<div className="flex justify-between items-center mb-4"> <div className="flex justify-between items-center mb-4">
<div className="flex items-center gap-4">
<h1 className="card-title text-2xl">Invite Codes</h1> <h1 className="card-title text-2xl">Invite Codes</h1>
<label className="cursor-pointer label gap-2">
<span className="label-text">Show Disabled Codes</span>
<input
type="checkbox"
className="toggle toggle-sm toggle-primary"
checked={showDisabled}
onChange={(e) => setShowDisabled(e.target.checked)}
/>
</label>
</div>
<button <button
onClick={createCode} onClick={createCode}
disabled={loading} disabled={loading}
@ -228,18 +243,16 @@ export default function InviteCodes() {
</div> </div>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
{sortCodes(codes).map((code, index) => ( {filterAndSortCodes(codes).map((code, index) => (
<div <div
key={index} key={index}
className={`card ${ className={`card ${code.disabled ? "bg-base-300 opacity-75" : "bg-base-200"
code.disabled ? "bg-base-300 opacity-75" : "bg-base-200"
} hover:shadow-md transition-shadow`} } hover:shadow-md transition-shadow`}
> >
<div className="card-body"> <div className="card-body">
<div className="flex justify-between items-start"> <div className="flex justify-between items-start">
<div <div
className={`font-mono text-lg bg-base-100 p-3 rounded-box select-all flex-1 mr-4 ${ className={`font-mono text-lg bg-base-100 p-3 rounded-box select-all flex-1 mr-4 ${code.disabled ? "opacity-50" : ""
code.disabled ? "opacity-50" : ""
}`} }`}
> >
{code.code} {code.code}
@ -276,8 +289,7 @@ export default function InviteCodes() {
</div> </div>
{code.uses.length > 0 && ( {code.uses.length > 0 && (
<div <div
className={`text-sm mt-2 ${ className={`text-sm mt-2 ${code.disabled ? "opacity-75" : "opacity-70"
code.disabled ? "opacity-75" : "opacity-70"
}`} }`}
> >
<span className="font-semibold">Used by:</span>{" "} <span className="font-semibold">Used by:</span>{" "}

View file

@ -5,6 +5,7 @@ import { useRefresh } from "../../../lib/RefreshContext";
interface User { interface User {
did: string; did: string;
handle?: string; handle?: string;
email?: string;
displayName?: string; displayName?: string;
description?: string; description?: string;
avatar?: { avatar?: {
@ -74,6 +75,7 @@ export default function UserList() {
const [settingsLoaded, setSettingsLoaded] = useState(false); const [settingsLoaded, setSettingsLoaded] = useState(false);
const [hasValidSettings, setHasValidSettings] = useState(false); const [hasValidSettings, setHasValidSettings] = useState(false);
const { lastUpdate } = useRefresh(); const { lastUpdate } = useRefresh();
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [deleteConfirmation, setDeleteConfirmation] = useState<{ const [deleteConfirmation, setDeleteConfirmation] = useState<{
show: boolean; show: boolean;
user: User | null; user: User | null;
@ -81,6 +83,7 @@ export default function UserList() {
show: false, show: false,
user: null, user: null,
}); });
const [editModal, setEditModal] = useState<{ const [editModal, setEditModal] = useState<{
show: boolean; show: boolean;
user: User | null; user: User | null;
@ -91,6 +94,15 @@ export default function UserList() {
newHandle: "", newHandle: "",
}); });
const [passwordResetModal, setPasswordResetModal] = useState<{
show: boolean;
user: User | null;
newPassword: string;
}>({
show: false,
user: null,
newPassword: "",
});
const resetState = () => { const resetState = () => {
setUsers([]); setUsers([]);
@ -108,23 +120,38 @@ export default function UserList() {
"Content-Type": "application/json", "Content-Type": "application/json",
}; };
const response = await fetch( // First get the account info which includes email
`${baseUrl}/xrpc/com.atproto.repo.getRecord?collection=app.bsky.actor.profile&repo=${did}&rkey=self`, const accountResponse = await fetch(
{ headers }, `${baseUrl}/xrpc/com.atproto.admin.getAccountInfo?did=${did}`,
{ headers }
); );
if (!response.ok) { if (!accountResponse.ok) {
throw new Error(`Failed to fetch profile: ${response.status}`); throw new Error(`Failed to fetch account info: ${accountResponse.status}`);
} }
const data = await response.json(); const accountData = await accountResponse.json();
// Then get the profile data as before
const profileResponse = await fetch(
`${baseUrl}/xrpc/com.atproto.repo.getRecord?collection=app.bsky.actor.profile&repo=${did}&rkey=self`,
{ headers }
);
if (!profileResponse.ok) {
throw new Error(`Failed to fetch profile: ${profileResponse.status}`);
}
const profileData = await profileResponse.json();
return { return {
did, did,
handle: data.value.handle, email: accountData.email, // Add email from account info
displayName: data.value.displayName, handle: profileData.value.handle,
description: data.value.description, displayName: profileData.value.displayName,
avatar: data.value.avatar, description: profileData.value.description,
createdAt: data.value.createdAt, avatar: profileData.value.avatar,
createdAt: profileData.value.createdAt,
}; };
} catch (error) { } catch (error) {
console.error(`Error fetching profile for ${did}:`, error); console.error(`Error fetching profile for ${did}:`, error);
@ -132,6 +159,7 @@ export default function UserList() {
} }
}; };
const deleteUser = async (did: string) => { const deleteUser = async (did: string) => {
try { try {
setLoading(true); setLoading(true);
@ -265,6 +293,94 @@ export default function UserList() {
} }
}; };
const requestPasswordResetEmail = async (user: User) => {
try {
setLoading(true);
setError(null);
const baseUrl = await Settings.getServiceUrl();
if (!baseUrl) throw new Error("Service URL not configured");
const headers = {
Authorization: await getAuthHeader(),
"Content-Type": "application/json",
};
const response = await fetch(
`${baseUrl}/xrpc/com.atproto.server.requestPasswordReset`,
{
method: "POST",
headers,
body: JSON.stringify({
email: user.email,
}),
}
);
if (response.ok) {
setSuccessMessage(`Password reset email sent to ${user.email}`);
setTimeout(() => setSuccessMessage(null), 3000);
}
if (!response.ok) {
const errorText = await response.text();
console.error("Error response:", errorText);
throw new Error(
`Failed to send reset email: ${response.status} ${response.statusText}`
);
}
} catch (err) {
console.error("Password reset email error:", err);
setError(
err instanceof Error ? err.message : "Failed to send password reset email"
);
} finally {
setLoading(false);
}
};
const resetUserPassword = async (did: string, newPassword: string) => {
try {
setLoading(true);
setError(null);
const baseUrl = await Settings.getServiceUrl();
if (!baseUrl) throw new Error("Service URL not configured");
const headers = {
Authorization: await getAuthHeader(),
"Content-Type": "application/json",
};
const response = await fetch(
`${baseUrl}/xrpc/com.atproto.admin.updateAccountPassword`,
{
method: "POST",
headers,
body: JSON.stringify({
did,
password: newPassword,
}),
}
);
if (!response.ok) {
const errorText = await response.text();
console.error("Error response:", errorText);
throw new Error(
`Failed to reset password: ${response.status} ${response.statusText}`
);
}
} catch (err) {
console.error("Password reset error:", err);
setError(err instanceof Error ? err.message : "Failed to reset password");
} finally {
setLoading(false);
}
};
useEffect(() => { useEffect(() => {
async function checkSettings() { async function checkSettings() {
try { try {
@ -359,6 +475,72 @@ export default function UserList() {
</button> </button>
</div> </div>
{/* Password Reset Modal */}
{passwordResetModal.show && passwordResetModal.user && (
<dialog className="modal modal-open">
<div className="modal-box">
<h3 className="font-bold text-lg">Reset Password</h3>
<p className="py-4">
Reset password for{" "}
<span className="font-semibold">
{passwordResetModal.user.displayName || "Anonymous"}
</span>
</p>
<div className="form-control">
<label className="label">
<span className="label-text">New Password</span>
</label>
<input
type="password"
placeholder="New password"
className="input input-bordered"
value={passwordResetModal.newPassword}
onChange={(e) =>
setPasswordResetModal({
...passwordResetModal,
newPassword: e.target.value,
})
}
/>
</div>
<div className="modal-action">
<button
className="btn"
onClick={() =>
setPasswordResetModal({ show: false, user: null, newPassword: "" })
}
>
Cancel
</button>
<button
className="btn btn-warning"
onClick={async () => {
if (passwordResetModal.user && passwordResetModal.newPassword) {
await resetUserPassword(
passwordResetModal.user.did,
passwordResetModal.newPassword
);
setPasswordResetModal({ show: false, user: null, newPassword: "" });
}
}}
disabled={!passwordResetModal.newPassword}
>
Reset Password
</button>
</div>
</div>
<form method="dialog" className="modal-backdrop">
<button
onClick={() =>
setPasswordResetModal({ show: false, user: null, newPassword: "" })
}
>
close
</button>
</form>
</dialog>
)}
{/* Edit Handle Modal */} {/* Edit Handle Modal */}
{editModal.show && editModal.user && ( {editModal.show && editModal.user && (
<dialog className="modal modal-open"> <dialog className="modal modal-open">
@ -496,6 +678,25 @@ export default function UserList() {
</div> </div>
)} )}
{successMessage && (
<div className="alert alert-success">
<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="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>{successMessage}</span>
</div>
)}
{loading ? ( {loading ? (
<div className="flex justify-center p-8"> <div className="flex justify-center p-8">
<span className="loading loading-spinner loading-lg"></span> <span className="loading loading-spinner loading-lg"></span>
@ -539,6 +740,31 @@ export default function UserList() {
> >
Edit Handle Edit Handle
</button> </button>
<button
onClick={() => setPasswordResetModal({
show: true,
user,
newPassword: "",
})}
className="btn btn-sm btn-warning"
disabled={loading}
>
Reset Password
</button>
<button
onClick={() => {
if (user.email) {
requestPasswordResetEmail(user);
} else {
setError("No email address available for this user");
}
}}
className="btn btn-sm btn-info"
disabled={loading || !user.email}
title={user.email ? "Send password reset email" : "No email available"}
>
Send Reset Email
</button>
<button <button
onClick={() => setDeleteConfirmation({ show: true, user })} onClick={() => setDeleteConfirmation({ show: true, user })}
className="btn btn-sm btn-error" className="btn btn-sm btn-error"
@ -547,6 +773,7 @@ export default function UserList() {
Delete Delete
</button> </button>
</div> </div>
</div> </div>
<div className="flex flex-wrap gap-2 mt-2"> <div className="flex flex-wrap gap-2 mt-2">
<div className="badge badge-ghost"> <div className="badge badge-ghost">