This commit is contained in:
parent
5a4079bc1f
commit
622bf8eb0d
3 changed files with 268 additions and 29 deletions
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "pdsmanager",
|
||||
"type": "module",
|
||||
"version": "0.2.2",
|
||||
"version": "0.3.0",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"build": "astro build",
|
||||
|
|
|
@ -28,6 +28,8 @@ export default function InviteCodes() {
|
|||
const [codes, setCodes] = useState<InviteCode[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showDisabled, setShowDisabled] = useState(false);
|
||||
|
||||
const { lastUpdate } = useRefresh();
|
||||
|
||||
const resetState = () => {
|
||||
|
@ -36,8 +38,10 @@ export default function InviteCodes() {
|
|||
setLoading(false);
|
||||
};
|
||||
|
||||
const sortCodes = (codes: InviteCode[]) => {
|
||||
return [...codes].sort((a, b) => {
|
||||
const filterAndSortCodes = (codes: InviteCode[]) => {
|
||||
return [...codes]
|
||||
.filter((code) => showDisabled || !code.disabled)
|
||||
.sort((a, b) => {
|
||||
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();
|
||||
|
@ -183,7 +187,18 @@ export default function InviteCodes() {
|
|||
<div className="card bg-base-100 shadow-xl">
|
||||
<div className="card-body">
|
||||
<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>
|
||||
<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
|
||||
onClick={createCode}
|
||||
disabled={loading}
|
||||
|
@ -228,18 +243,16 @@ export default function InviteCodes() {
|
|||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{sortCodes(codes).map((code, index) => (
|
||||
{filterAndSortCodes(codes).map((code, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`card ${
|
||||
code.disabled ? "bg-base-300 opacity-75" : "bg-base-200"
|
||||
className={`card ${code.disabled ? "bg-base-300 opacity-75" : "bg-base-200"
|
||||
} hover:shadow-md transition-shadow`}
|
||||
>
|
||||
<div className="card-body">
|
||||
<div className="flex justify-between items-start">
|
||||
<div
|
||||
className={`font-mono text-lg bg-base-100 p-3 rounded-box select-all flex-1 mr-4 ${
|
||||
code.disabled ? "opacity-50" : ""
|
||||
className={`font-mono text-lg bg-base-100 p-3 rounded-box select-all flex-1 mr-4 ${code.disabled ? "opacity-50" : ""
|
||||
}`}
|
||||
>
|
||||
{code.code}
|
||||
|
@ -276,8 +289,7 @@ export default function InviteCodes() {
|
|||
</div>
|
||||
{code.uses.length > 0 && (
|
||||
<div
|
||||
className={`text-sm mt-2 ${
|
||||
code.disabled ? "opacity-75" : "opacity-70"
|
||||
className={`text-sm mt-2 ${code.disabled ? "opacity-75" : "opacity-70"
|
||||
}`}
|
||||
>
|
||||
<span className="font-semibold">Used by:</span>{" "}
|
||||
|
|
|
@ -5,6 +5,7 @@ import { useRefresh } from "../../../lib/RefreshContext";
|
|||
interface User {
|
||||
did: string;
|
||||
handle?: string;
|
||||
email?: string;
|
||||
displayName?: string;
|
||||
description?: string;
|
||||
avatar?: {
|
||||
|
@ -74,6 +75,7 @@ export default function UserList() {
|
|||
const [settingsLoaded, setSettingsLoaded] = useState(false);
|
||||
const [hasValidSettings, setHasValidSettings] = useState(false);
|
||||
const { lastUpdate } = useRefresh();
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
const [deleteConfirmation, setDeleteConfirmation] = useState<{
|
||||
show: boolean;
|
||||
user: User | null;
|
||||
|
@ -81,6 +83,7 @@ export default function UserList() {
|
|||
show: false,
|
||||
user: null,
|
||||
});
|
||||
|
||||
const [editModal, setEditModal] = useState<{
|
||||
show: boolean;
|
||||
user: User | null;
|
||||
|
@ -91,6 +94,15 @@ export default function UserList() {
|
|||
newHandle: "",
|
||||
});
|
||||
|
||||
const [passwordResetModal, setPasswordResetModal] = useState<{
|
||||
show: boolean;
|
||||
user: User | null;
|
||||
newPassword: string;
|
||||
}>({
|
||||
show: false,
|
||||
user: null,
|
||||
newPassword: "",
|
||||
});
|
||||
|
||||
const resetState = () => {
|
||||
setUsers([]);
|
||||
|
@ -108,23 +120,38 @@ export default function UserList() {
|
|||
"Content-Type": "application/json",
|
||||
};
|
||||
|
||||
const response = await fetch(
|
||||
`${baseUrl}/xrpc/com.atproto.repo.getRecord?collection=app.bsky.actor.profile&repo=${did}&rkey=self`,
|
||||
{ headers },
|
||||
// First get the account info which includes email
|
||||
const accountResponse = await fetch(
|
||||
`${baseUrl}/xrpc/com.atproto.admin.getAccountInfo?did=${did}`,
|
||||
{ headers }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch profile: ${response.status}`);
|
||||
if (!accountResponse.ok) {
|
||||
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 {
|
||||
did,
|
||||
handle: data.value.handle,
|
||||
displayName: data.value.displayName,
|
||||
description: data.value.description,
|
||||
avatar: data.value.avatar,
|
||||
createdAt: data.value.createdAt,
|
||||
email: accountData.email, // Add email from account info
|
||||
handle: profileData.value.handle,
|
||||
displayName: profileData.value.displayName,
|
||||
description: profileData.value.description,
|
||||
avatar: profileData.value.avatar,
|
||||
createdAt: profileData.value.createdAt,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Error fetching profile for ${did}:`, error);
|
||||
|
@ -132,6 +159,7 @@ export default function UserList() {
|
|||
}
|
||||
};
|
||||
|
||||
|
||||
const deleteUser = async (did: string) => {
|
||||
try {
|
||||
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(() => {
|
||||
async function checkSettings() {
|
||||
try {
|
||||
|
@ -359,6 +475,72 @@ export default function UserList() {
|
|||
</button>
|
||||
</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 */}
|
||||
{editModal.show && editModal.user && (
|
||||
<dialog className="modal modal-open">
|
||||
|
@ -496,6 +678,25 @@ export default function UserList() {
|
|||
</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 ? (
|
||||
<div className="flex justify-center p-8">
|
||||
<span className="loading loading-spinner loading-lg"></span>
|
||||
|
@ -539,6 +740,31 @@ export default function UserList() {
|
|||
>
|
||||
Edit Handle
|
||||
</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
|
||||
onClick={() => setDeleteConfirmation({ show: true, user })}
|
||||
className="btn btn-sm btn-error"
|
||||
|
@ -547,6 +773,7 @@ export default function UserList() {
|
|||
Delete
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
<div className="badge badge-ghost">
|
||||
|
|
Loading…
Add table
Reference in a new issue