This commit is contained in:
parent
5a4079bc1f
commit
622bf8eb0d
3 changed files with 268 additions and 29 deletions
|
@ -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",
|
||||||
|
|
|
@ -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,12 +38,14 @@ export default function InviteCodes() {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const sortCodes = (codes: InviteCode[]) => {
|
const filterAndSortCodes = (codes: InviteCode[]) => {
|
||||||
return [...codes].sort((a, b) => {
|
return [...codes]
|
||||||
if (a.disabled && !b.disabled) return 1;
|
.filter((code) => showDisabled || !code.disabled)
|
||||||
if (!a.disabled && b.disabled) return -1;
|
.sort((a, b) => {
|
||||||
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
|
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();
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchCodes = async () => {
|
const fetchCodes = async () => {
|
||||||
|
@ -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">
|
||||||
<h1 className="card-title text-2xl">Invite Codes</h1>
|
<div className="flex items-center gap-4">
|
||||||
|
<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,19 +243,17 @@ 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}
|
||||||
</div>
|
</div>
|
||||||
|
@ -276,9 +289,8 @@ 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>{" "}
|
||||||
{code.uses.map((use) => use.usedBy).join(", ")}
|
{code.uses.map((use) => use.usedBy).join(", ")}
|
||||||
|
|
|
@ -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">
|
||||||
|
|
Loading…
Add table
Reference in a new issue