Files
quietthanks/src/app/admin/page.tsx
Gemini Agent 504f07a106 Add multiple entries per day, user management, reminders, and AI reflections
- Multiple entries per day: Home page now starts fresh, Save & New button
- Admin user management: Add/delete users, reset passwords, toggle admin
- Daily reminders: Browser notifications at configurable time
- AI reflections: Generate insights from entries using Claude API
- Remove cloud sync placeholder (already have user accounts)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 12:05:39 +00:00

424 lines
14 KiB
TypeScript

"use client";
import { useState, useEffect, useCallback } from "react";
import { useRouter } from "next/navigation";
import { AppShell } from "@/components/AppShell";
import { useAuth } from "@/components/AuthProvider";
import { APP_NAME } from "@/lib/constants";
import {
ArrowLeft,
Loader2,
Plus,
Trash2,
Key,
Shield,
ShieldOff,
User,
X,
} from "lucide-react";
import Link from "next/link";
interface UserData {
id: string;
email: string;
name: string | null;
isAdmin: boolean;
createdAt: number;
}
export default function AdminPage() {
const router = useRouter();
const { user } = useAuth();
const [users, setUsers] = useState<UserData[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Modal states
const [showAddUser, setShowAddUser] = useState(false);
const [showResetPassword, setShowResetPassword] = useState<UserData | null>(null);
const [showDeleteConfirm, setShowDeleteConfirm] = useState<UserData | null>(null);
// Form states
const [newEmail, setNewEmail] = useState("");
const [newPassword, setNewPassword] = useState("");
const [newName, setNewName] = useState("");
const [newIsAdmin, setNewIsAdmin] = useState(false);
const [resetPassword, setResetPassword] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const loadUsers = useCallback(async () => {
try {
const res = await fetch("/api/admin/users");
if (res.status === 403) {
router.push("/");
return;
}
if (res.ok) {
setUsers(await res.json());
} else {
setError("Failed to load users");
}
} catch {
setError("Failed to load users");
} finally {
setIsLoading(false);
}
}, [router]);
useEffect(() => {
if (user && !user.isAdmin) {
router.push("/");
return;
}
loadUsers();
}, [user, router, loadUsers]);
const handleAddUser = async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
setError(null);
try {
const res = await fetch("/api/admin/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: newEmail,
password: newPassword,
name: newName,
isAdmin: newIsAdmin,
}),
});
if (res.ok) {
setShowAddUser(false);
setNewEmail("");
setNewPassword("");
setNewName("");
setNewIsAdmin(false);
loadUsers();
} else {
const data = await res.json();
setError(data.error || "Failed to create user");
}
} catch {
setError("Failed to create user");
} finally {
setIsSubmitting(false);
}
};
const handleResetPassword = async (e: React.FormEvent) => {
e.preventDefault();
if (!showResetPassword) return;
setIsSubmitting(true);
setError(null);
try {
const res = await fetch(`/api/admin/users/${showResetPassword.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password: resetPassword }),
});
if (res.ok) {
setShowResetPassword(null);
setResetPassword("");
} else {
const data = await res.json();
setError(data.error || "Failed to reset password");
}
} catch {
setError("Failed to reset password");
} finally {
setIsSubmitting(false);
}
};
const handleDeleteUser = async () => {
if (!showDeleteConfirm) return;
setIsSubmitting(true);
setError(null);
try {
const res = await fetch(`/api/admin/users/${showDeleteConfirm.id}`, {
method: "DELETE",
});
if (res.ok) {
setShowDeleteConfirm(null);
loadUsers();
} else {
const data = await res.json();
setError(data.error || "Failed to delete user");
}
} catch {
setError("Failed to delete user");
} finally {
setIsSubmitting(false);
}
};
const toggleAdmin = async (targetUser: UserData) => {
try {
const res = await fetch(`/api/admin/users/${targetUser.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ isAdmin: !targetUser.isAdmin }),
});
if (res.ok) {
loadUsers();
} else {
const data = await res.json();
setError(data.error || "Failed to update user");
}
} catch {
setError("Failed to update user");
}
};
if (!user?.isAdmin) {
return null;
}
return (
<AppShell>
<header className="flex items-center gap-3 mb-6">
<Link href="/settings" className="p-2 -ml-2 text-muted hover:text-foreground">
<ArrowLeft size={20} />
</Link>
<div>
<h1 className="text-xl font-light">{APP_NAME}</h1>
<p className="text-sm text-muted">User Management</p>
</div>
</header>
{error && (
<div className="mb-4 p-3 bg-red-500/10 border border-red-500/30 rounded-lg text-red-400 text-sm">
{error}
</div>
)}
<div className="mb-4">
<button
onClick={() => setShowAddUser(true)}
className="flex items-center gap-2 px-4 py-2 bg-accent text-white rounded-lg hover:bg-accent/90 transition-colors"
>
<Plus size={16} />
Add User
</button>
</div>
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="animate-spin text-muted" size={24} />
</div>
) : (
<div className="space-y-3">
{users.map((u) => (
<div
key={u.id}
className="p-4 bg-surface border border-border rounded-xl flex items-center gap-4"
>
<div className="p-2 bg-background rounded-lg">
<User size={20} className="text-muted" />
</div>
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{u.email}</p>
<p className="text-sm text-muted">
{u.name || "No name"} {u.isAdmin && "Admin • "}
{new Date(u.createdAt).toLocaleDateString()}
</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => toggleAdmin(u)}
disabled={u.id === user.id}
className="p-2 text-muted hover:text-foreground disabled:opacity-50 disabled:cursor-not-allowed"
title={u.isAdmin ? "Remove admin" : "Make admin"}
>
{u.isAdmin ? <ShieldOff size={18} /> : <Shield size={18} />}
</button>
<button
onClick={() => setShowResetPassword(u)}
className="p-2 text-muted hover:text-foreground"
title="Reset password"
>
<Key size={18} />
</button>
<button
onClick={() => setShowDeleteConfirm(u)}
disabled={u.id === user.id}
className="p-2 text-muted hover:text-red-400 disabled:opacity-50 disabled:cursor-not-allowed"
title="Delete user"
>
<Trash2 size={18} />
</button>
</div>
</div>
))}
</div>
)}
{/* Add User Modal */}
{showAddUser && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
<div className="bg-surface border border-border rounded-xl p-6 max-w-md w-full">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-medium">Add User</h2>
<button
onClick={() => setShowAddUser(false)}
className="p-1 text-muted hover:text-foreground"
>
<X size={20} />
</button>
</div>
<form onSubmit={handleAddUser} className="space-y-4">
<div>
<label className="block text-sm text-muted mb-1">Email</label>
<input
type="email"
value={newEmail}
onChange={(e) => setNewEmail(e.target.value)}
required
className="w-full px-3 py-2 bg-background border border-border rounded-lg focus:outline-none focus:border-muted"
/>
</div>
<div>
<label className="block text-sm text-muted mb-1">Password</label>
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
required
minLength={6}
className="w-full px-3 py-2 bg-background border border-border rounded-lg focus:outline-none focus:border-muted"
/>
</div>
<div>
<label className="block text-sm text-muted mb-1">Name (optional)</label>
<input
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
className="w-full px-3 py-2 bg-background border border-border rounded-lg focus:outline-none focus:border-muted"
/>
</div>
<div>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={newIsAdmin}
onChange={(e) => setNewIsAdmin(e.target.checked)}
className="w-4 h-4 rounded border-border bg-background"
/>
<span className="text-sm">Admin privileges</span>
</label>
</div>
<div className="flex gap-3 justify-end pt-2">
<button
type="button"
onClick={() => setShowAddUser(false)}
className="px-4 py-2 text-sm text-muted hover:text-foreground"
>
Cancel
</button>
<button
type="submit"
disabled={isSubmitting}
className="flex items-center gap-2 px-4 py-2 text-sm bg-accent text-white rounded-lg hover:bg-accent/90 disabled:opacity-50"
>
{isSubmitting && <Loader2 className="animate-spin" size={14} />}
Add User
</button>
</div>
</form>
</div>
</div>
)}
{/* Reset Password Modal */}
{showResetPassword && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
<div className="bg-surface border border-border rounded-xl p-6 max-w-md w-full">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-medium">Reset Password</h2>
<button
onClick={() => setShowResetPassword(null)}
className="p-1 text-muted hover:text-foreground"
>
<X size={20} />
</button>
</div>
<p className="text-sm text-muted mb-4">
Reset password for <strong>{showResetPassword.email}</strong>
</p>
<form onSubmit={handleResetPassword} className="space-y-4">
<div>
<label className="block text-sm text-muted mb-1">New Password</label>
<input
type="password"
value={resetPassword}
onChange={(e) => setResetPassword(e.target.value)}
required
minLength={6}
className="w-full px-3 py-2 bg-background border border-border rounded-lg focus:outline-none focus:border-muted"
/>
</div>
<div className="flex gap-3 justify-end pt-2">
<button
type="button"
onClick={() => setShowResetPassword(null)}
className="px-4 py-2 text-sm text-muted hover:text-foreground"
>
Cancel
</button>
<button
type="submit"
disabled={isSubmitting}
className="flex items-center gap-2 px-4 py-2 text-sm bg-accent text-white rounded-lg hover:bg-accent/90 disabled:opacity-50"
>
{isSubmitting && <Loader2 className="animate-spin" size={14} />}
Reset Password
</button>
</div>
</form>
</div>
</div>
)}
{/* Delete Confirmation Modal */}
{showDeleteConfirm && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
<div className="bg-surface border border-border rounded-xl p-6 max-w-sm w-full">
<h2 className="text-lg font-medium mb-2">Delete User?</h2>
<p className="text-sm text-muted mb-6">
This will permanently delete <strong>{showDeleteConfirm.email}</strong> and
all their entries. This cannot be undone.
</p>
<div className="flex gap-3 justify-end">
<button
onClick={() => setShowDeleteConfirm(null)}
className="px-4 py-2 text-sm text-muted hover:text-foreground"
>
Cancel
</button>
<button
onClick={handleDeleteUser}
disabled={isSubmitting}
className="flex items-center gap-2 px-4 py-2 text-sm bg-red-500 text-white rounded-lg hover:bg-red-600 disabled:opacity-50"
>
{isSubmitting && <Loader2 className="animate-spin" size={14} />}
Delete
</button>
</div>
</div>
</div>
)}
</AppShell>
);
}