Refactor admin API and user authentication flow
- Updated API endpoints for user login and organization registration. - Enhanced session management by introducing organization-specific tokens. - Refactored user authentication logic in App component to handle organization memberships. - Improved loading and error handling in user interface components. - Added new components for organization management and Google Drive integration. - Adjusted timeout settings for API requests to improve performance.
This commit is contained in:
parent
a56e424dba
commit
266789daf5
@ -1,6 +1,6 @@
|
||||
# Metatron.Drive Admin
|
||||
|
||||
Administrator frontend for Metatron.Drive user management.
|
||||
Organization signup and administration portal for Metatron.Drive. Owners connect Google Drive, share their organization code, and approve or reject membership requests.
|
||||
|
||||
## Development
|
||||
|
||||
@ -15,4 +15,4 @@ npm run dev
|
||||
npm run build
|
||||
```
|
||||
|
||||
Configure the API using `VITE_API_BASE_URL`. Administrator credentials are configured only on the backend through `ADMIN_USERNAME` and `ADMIN_PASSWORD`.
|
||||
Configure the API using `VITE_API_BASE_URL`. Organization administrators use their global Metatron.Drive user account. The backend-only `ADMIN_USERNAME` and `ADMIN_PASSWORD` remain reserved for the future platform-super-admin API.
|
||||
|
||||
269
src/App.jsx
269
src/App.jsx
@ -1,188 +1,199 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import {
|
||||
Ban, CheckCircle2, ChevronLeft, ChevronRight, Cloud, Database,
|
||||
Edit3, Eye, EyeOff, File, Folder, HardDrive, KeyRound, LoaderCircle, LockKeyhole,
|
||||
LogOut, RefreshCw, Search, ShieldCheck, Trash2, UserCheck, Users, X, XCircle,
|
||||
Ban, ChevronLeft, ChevronRight, Cloud, CloudCog, Copy,
|
||||
ExternalLink, File, Folder, HardDrive, KeyRound, LoaderCircle,
|
||||
LogOut, RefreshCw, Search, ShieldCheck, UserCheck, UserPlus, Users, XCircle,
|
||||
} from 'lucide-react'
|
||||
import { adminApi, messageFrom } from './api.js'
|
||||
|
||||
export default function App() {
|
||||
const [status, setStatus] = useState(() => sessionStorage.getItem('metatron-admin-token') ? 'loading' : 'signedOut')
|
||||
const [admin, setAdmin] = useState(null)
|
||||
const tokenKey = 'metatron-org-admin-token'
|
||||
const organizationKey = 'metatron-org-admin-organization'
|
||||
|
||||
useEffect(() => {
|
||||
const token = sessionStorage.getItem('metatron-admin-token')
|
||||
if (!token) return
|
||||
adminApi.me().then((profile) => { setAdmin(profile); setStatus('signedIn') }).catch(() => {
|
||||
sessionStorage.removeItem('metatron-admin-token')
|
||||
setStatus('signedOut')
|
||||
})
|
||||
}, [])
|
||||
|
||||
function authenticated(result) {
|
||||
sessionStorage.setItem('metatron-admin-token', result.token)
|
||||
setAdmin(result.admin)
|
||||
setStatus('signedIn')
|
||||
function adminMemberships(user) {
|
||||
return (user?.memberships || []).filter((item) => item.status === 'APPROVED' && ['OWNER', 'ADMIN'].includes(item.role) && item.organization?.status === 'ACTIVE')
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [status, setStatus] = useState(() => sessionStorage.getItem(tokenKey) ? 'loading' : 'signedOut')
|
||||
const [user, setUser] = useState(null)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const acceptSession = useCallback((result) => {
|
||||
const memberships = adminMemberships(result.user)
|
||||
if (!memberships.length) throw new Error('This account is not an administrator of an active organization.')
|
||||
sessionStorage.setItem(tokenKey, result.token || sessionStorage.getItem(tokenKey))
|
||||
const saved = sessionStorage.getItem(organizationKey)
|
||||
if (!memberships.some((item) => item.organization.id === saved)) sessionStorage.setItem(organizationKey, memberships[0].organization.id)
|
||||
setUser(result.user)
|
||||
setStatus('signedIn')
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionStorage.getItem(tokenKey)) return
|
||||
adminApi.me().then((nextUser) => acceptSession({ user: nextUser })).catch((requestError) => {
|
||||
sessionStorage.removeItem(tokenKey)
|
||||
setError(messageFrom(requestError))
|
||||
setStatus('signedOut')
|
||||
})
|
||||
}, [acceptSession])
|
||||
|
||||
function logout() {
|
||||
sessionStorage.removeItem('metatron-admin-token')
|
||||
setAdmin(null)
|
||||
sessionStorage.removeItem(tokenKey)
|
||||
sessionStorage.removeItem(organizationKey)
|
||||
setUser(null)
|
||||
setStatus('signedOut')
|
||||
}
|
||||
|
||||
if (status === 'loading') return <LoadingScreen />
|
||||
if (status === 'signedOut') return <LoginScreen onAuthenticated={authenticated} />
|
||||
return <Dashboard admin={admin} onLogout={logout} />
|
||||
if (status === 'signedOut') return <AuthScreen onAuthenticated={acceptSession} initialError={error} />
|
||||
return <Dashboard user={user} onLogout={logout} />
|
||||
}
|
||||
|
||||
function LoadingScreen() {
|
||||
return <main className="flex min-h-screen items-center justify-center bg-[#101d42]"><LoaderCircle className="animate-spin text-emerald-300" size={38} /></main>
|
||||
}
|
||||
|
||||
function LoginScreen({ onAuthenticated }) {
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [hidden, setHidden] = useState(true)
|
||||
function AuthScreen({ onAuthenticated, initialError }) {
|
||||
const [mode, setMode] = useState('login')
|
||||
const [login, setLogin] = useState({ username: '', password: '' })
|
||||
const [form, setForm] = useState({ organizationName: '', name: '', username: '', email: '', phone: '', password: '', age: '18', gender: 'Prefer not to say' })
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [error, setError] = useState(initialError || '')
|
||||
|
||||
async function submit(event) {
|
||||
event.preventDefault()
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try { onAuthenticated(await adminApi.login(username.trim(), password)) }
|
||||
async function submitLogin(event) {
|
||||
event.preventDefault(); setBusy(true); setError('')
|
||||
try { onAuthenticated(await adminApi.login(login.username.trim(), login.password)) }
|
||||
catch (requestError) { setError(messageFrom(requestError)) }
|
||||
finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return <main className="grid min-h-screen lg:grid-cols-[1.1fr_0.9fr]">
|
||||
async function submitSignup(event) {
|
||||
event.preventDefault(); setBusy(true); setError('')
|
||||
try { onAuthenticated(await adminApi.registerOrganization({ ...form, age: Number(form.age), username: form.username.trim().toLowerCase() })) }
|
||||
catch (requestError) { setError(messageFrom(requestError)) }
|
||||
finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return <main className="grid min-h-screen lg:grid-cols-[1.02fr_0.98fr]">
|
||||
<section className="relative hidden overflow-hidden bg-[#101d42] p-16 text-white lg:flex lg:flex-col lg:justify-between">
|
||||
<div className="absolute -left-40 -top-40 h-[30rem] w-[30rem] rounded-full bg-blue-600/20" />
|
||||
<div className="absolute -bottom-48 right-0 h-[34rem] w-[34rem] rounded-full bg-emerald-400/10" />
|
||||
<div className="relative flex items-center gap-3 text-xl font-black"><span className="flex h-11 w-11 items-center justify-center rounded-2xl bg-blue-600"><Cloud fill="currentColor" size={25} /></span>Metatron.Drive</div>
|
||||
<div className="relative max-w-xl"><p className="mb-4 text-sm font-bold uppercase tracking-[.25em] text-emerald-300">Administrator control</p><h1 className="text-5xl font-black leading-tight">Manage your digital family securely.</h1><p className="mt-6 text-lg leading-8 text-white/60">Review accounts, update user details, reset passwords, suspend access, and manage database records.</p></div>
|
||||
<p className="relative text-sm text-white/35">Restricted administrator access</p>
|
||||
<div className="relative max-w-xl"><p className="mb-4 text-sm font-bold uppercase tracking-[.25em] text-emerald-300">Organization workspace</p><h1 className="text-5xl font-black leading-tight">Your organization.<br />Your Drive.<br />Your control.</h1><p className="mt-6 text-lg leading-8 text-white/60">Connect Google Drive, approve members, and manage one secure digital workspace.</p></div>
|
||||
<p className="relative text-sm text-white/35">Multi-tenant organization administration</p>
|
||||
</section>
|
||||
<section className="scrollbar flex max-h-screen items-center justify-center overflow-y-auto bg-[#f5f7fb] p-8">
|
||||
{mode === 'login' ? <form className="w-full max-w-md" onSubmit={submitLogin}>
|
||||
<span className="mb-7 flex h-14 w-14 items-center justify-center rounded-2xl bg-blue-600 text-white"><ShieldCheck size={29} /></span>
|
||||
<h2 className="text-3xl font-black text-slate-900">Organization admin</h2><p className="mt-2 text-slate-500">Sign in with your global Metatron.Drive account.</p>
|
||||
<div className="mt-8 space-y-4"><Input label="Username" value={login.username} set={(value) => setLogin({ ...login, username: value })} /><Input label="Password" type="password" value={login.password} set={(value) => setLogin({ ...login, password: value })} /></div>
|
||||
{error && <ErrorBox text={error} />}
|
||||
<button className="btn-primary mt-6 w-full py-3" disabled={busy}>{busy ? <LoaderCircle className="animate-spin" size={20} /> : <><KeyRound size={18} /> Sign in</>}</button>
|
||||
<button type="button" className="mt-5 w-full text-sm font-bold text-blue-600" onClick={() => { setMode('signup'); setError('') }}>Create a new organization</button>
|
||||
</form> : <form className="w-full max-w-2xl py-8" onSubmit={submitSignup}>
|
||||
<h2 className="text-3xl font-black text-slate-900">Create your organization</h2><p className="mt-2 text-slate-500">The organization is active immediately. You can connect Drive after signup.</p>
|
||||
<div className="mt-7 grid grid-cols-2 gap-4"><div className="col-span-2"><Input label="Organization name" value={form.organizationName} set={(value) => setForm({ ...form, organizationName: value })} /></div><Input label="Your full name" value={form.name} set={(value) => setForm({ ...form, name: value })} /><Input label="Username" value={form.username} set={(value) => setForm({ ...form, username: value })} /><Input label="Email" type="email" value={form.email} set={(value) => setForm({ ...form, email: value })} /><Input label="Phone" value={form.phone} set={(value) => setForm({ ...form, phone: value })} /><Input label="Age" type="number" value={form.age} set={(value) => setForm({ ...form, age: value })} /><label><span className="mb-1.5 block text-xs font-bold text-slate-500">Gender</span><select className="field" value={form.gender} onChange={(event) => setForm({ ...form, gender: event.target.value })}><option>Male</option><option>Female</option><option>Non-binary</option><option>Prefer not to say</option></select></label><div className="col-span-2"><Input label="Password" type="password" value={form.password} set={(value) => setForm({ ...form, password: value })} /></div></div>
|
||||
{error && <ErrorBox text={error} />}
|
||||
<button className="btn-primary mt-6 w-full py-3" disabled={busy}>{busy ? <LoaderCircle className="animate-spin" size={20} /> : <><UserPlus size={18} /> Create organization</>}</button>
|
||||
<button type="button" className="mt-5 w-full text-sm font-bold text-blue-600" onClick={() => { setMode('login'); setError('') }}>Back to sign in</button>
|
||||
</form>}
|
||||
</section>
|
||||
<section className="flex items-center justify-center bg-[#f5f7fb] p-8"><form className="w-full max-w-md" onSubmit={submit}>
|
||||
<span className="mb-7 flex h-14 w-14 items-center justify-center rounded-2xl bg-blue-600 text-white shadow-lg shadow-blue-200"><ShieldCheck size={29} /></span>
|
||||
<h2 className="text-3xl font-black text-slate-900">Admin sign in</h2><p className="mt-2 text-slate-500">Enter the administrator credentials configured on the API server.</p>
|
||||
<div className="mt-8 space-y-4"><label className="relative block"><Users className="absolute left-4 top-3 text-slate-400" size={19} /><input className="field pl-11" value={username} onChange={(event) => setUsername(event.target.value)} placeholder="Administrator username" autoFocus /></label><label className="relative block"><LockKeyhole className="absolute left-4 top-3 text-slate-400" size={19} /><input className="field px-11" value={password} onChange={(event) => setPassword(event.target.value)} placeholder="Administrator password" type={hidden ? 'password' : 'text'} /><button className="icon-btn absolute right-2 top-1.5" type="button" onClick={() => setHidden(!hidden)}>{hidden ? <Eye size={18} /> : <EyeOff size={18} />}</button></label></div>
|
||||
{error && <div className="mt-5 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm font-semibold text-red-700">{error}</div>}
|
||||
<button className="btn-primary mt-6 w-full py-3" disabled={busy || !username || !password}>{busy ? <LoaderCircle className="animate-spin" size={20} /> : <><KeyRound size={18} /> Sign in securely</>}</button>
|
||||
</form></section>
|
||||
</main>
|
||||
}
|
||||
|
||||
function Dashboard({ admin, onLogout }) {
|
||||
function Dashboard({ user, onLogout }) {
|
||||
const organizations = adminMemberships(user)
|
||||
const [organizationId, setOrganizationId] = useState(() => sessionStorage.getItem(organizationKey) || organizations[0].organization.id)
|
||||
const organization = organizations.find((item) => item.organization.id === organizationId)?.organization || organizations[0].organization
|
||||
const [stats, setStats] = useState(null)
|
||||
const [users, setUsers] = useState([])
|
||||
const [drive, setDrive] = useState(null)
|
||||
const [rows, setRows] = useState([])
|
||||
const [pagination, setPagination] = useState({ page: 1, pages: 1, total: 0 })
|
||||
const [search, setSearch] = useState('')
|
||||
const [status, setStatus] = useState('ALL')
|
||||
const [searchInput, setSearchInput] = useState('')
|
||||
const [filter, setFilter] = useState('all')
|
||||
const [search, setSearch] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [editing, setEditing] = useState(null)
|
||||
const [toast, setToast] = useState(null)
|
||||
const [connectOpen, setConnectOpen] = useState(false)
|
||||
|
||||
const load = useCallback(async (page = 1) => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
if (!organizationId) return
|
||||
setLoading(true); setError('')
|
||||
try {
|
||||
const [nextStats, result] = await Promise.all([
|
||||
adminApi.stats(),
|
||||
adminApi.users({ search, status: filter, page, limit: 25 }),
|
||||
])
|
||||
setStats(nextStats)
|
||||
setUsers(result.users)
|
||||
setPagination(result.pagination)
|
||||
} catch (requestError) {
|
||||
if (requestError?.response?.status === 401) onLogout()
|
||||
else setError(messageFrom(requestError))
|
||||
} finally { setLoading(false) }
|
||||
}, [filter, onLogout, search])
|
||||
const [nextStats, nextDrive, result] = await Promise.all([adminApi.stats(), adminApi.drive(), adminApi.memberships({ page, limit: 25, status, search })])
|
||||
setStats(nextStats); setDrive(nextDrive); setRows(result.memberships); setPagination(result.pagination)
|
||||
} catch (requestError) { setError(messageFrom(requestError)) }
|
||||
finally { setLoading(false) }
|
||||
}, [search, status, organizationId])
|
||||
|
||||
useEffect(() => { const timer = setTimeout(() => load(1), 0); return () => clearTimeout(timer) }, [load])
|
||||
useEffect(() => { if (!toast) return undefined; const timer = setTimeout(() => setToast(null), 3500); return () => clearTimeout(timer) }, [toast])
|
||||
useEffect(() => { sessionStorage.setItem(organizationKey, organizationId); const timer = setTimeout(() => load(1), 0); return () => clearTimeout(timer) }, [organizationId, load])
|
||||
|
||||
function submitSearch(event) { event.preventDefault(); setSearch(searchInput.trim()) }
|
||||
|
||||
async function saveUser(id, input) {
|
||||
try {
|
||||
await adminApi.updateUser(id, input)
|
||||
setEditing(null)
|
||||
setToast({ type: 'success', text: 'User updated successfully.' })
|
||||
load(pagination.page)
|
||||
} catch (requestError) { setToast({ type: 'error', text: messageFrom(requestError) }) }
|
||||
async function changeMembership(id, input) {
|
||||
try { await adminApi.updateMembership(id, input); await load(pagination.page) }
|
||||
catch (requestError) { setError(messageFrom(requestError)) }
|
||||
}
|
||||
|
||||
async function toggleSuspension(user) {
|
||||
try {
|
||||
await adminApi.updateUser(user.id, { isSuspended: !user.isSuspended })
|
||||
setToast({ type: 'success', text: user.isSuspended ? 'User access restored.' : 'User suspended.' })
|
||||
load(pagination.page)
|
||||
} catch (requestError) { setToast({ type: 'error', text: messageFrom(requestError) }) }
|
||||
}
|
||||
|
||||
async function deleteUser(user) {
|
||||
if (!window.confirm(`Delete ${user.name} from the database? Their Google Drive files will remain untouched.`)) return
|
||||
try {
|
||||
await adminApi.deleteUser(user.id)
|
||||
setToast({ type: 'success', text: 'User database records deleted.' })
|
||||
load(Math.min(pagination.page, pagination.pages))
|
||||
} catch (requestError) { setToast({ type: 'error', text: messageFrom(requestError) }) }
|
||||
function reject(row) {
|
||||
const reason = window.prompt(`Why are you rejecting ${row.user.name}'s application?`)
|
||||
if (reason?.trim()) changeMembership(row.id, { status: 'REJECTED', rejectionReason: reason.trim() })
|
||||
}
|
||||
|
||||
return <main className="min-h-screen bg-[#f5f7fb]">
|
||||
<aside className="fixed inset-y-0 left-0 z-20 flex w-64 flex-col bg-[#101d42] p-5 text-white">
|
||||
<aside className="fixed inset-y-0 left-0 z-20 flex w-72 flex-col bg-[#101d42] p-5 text-white">
|
||||
<div className="flex items-center gap-3 px-2 text-lg font-black"><span className="flex h-10 w-10 items-center justify-center rounded-2xl bg-blue-600"><Cloud fill="currentColor" size={22} /></span>Metatron.Drive</div>
|
||||
<div className="mt-10 rounded-xl bg-white/10 px-4 py-3"><div className="flex items-center gap-3"><Database className="text-emerald-300" size={20} /><div><p className="text-sm font-bold">User database</p><p className="text-xs text-white/40">Management console</p></div></div></div>
|
||||
<div className="mt-auto rounded-2xl border border-white/10 p-3"><p className="text-xs text-white/40">Signed in as</p><p className="mt-1 text-sm font-bold">{admin.username}</p><button className="mt-3 flex items-center gap-2 text-xs font-bold text-white/60 hover:text-white" onClick={onLogout}><LogOut size={15} /> Sign out</button></div>
|
||||
<label className="mt-9 text-xs font-bold uppercase tracking-wider text-white/40">Organization</label><select className="mt-2 rounded-xl border border-white/10 bg-white/10 px-3 py-3 text-sm font-bold" value={organizationId} onChange={(event) => setOrganizationId(event.target.value)}>{organizations.map((item) => <option className="text-slate-900" key={item.organization.id} value={item.organization.id}>{item.organization.name}</option>)}</select>
|
||||
<div className="mt-4 rounded-xl bg-white/10 px-4 py-3"><p className="text-xs text-white/40">Join code</p><div className="mt-1 flex items-center justify-between"><strong>{organization.code}</strong><button title="Copy code" onClick={() => navigator.clipboard.writeText(organization.code)}><Copy size={16} /></button></div></div>
|
||||
<button className="mt-3 flex items-center gap-3 rounded-xl bg-white/10 px-4 py-3 text-left text-sm font-bold" onClick={() => setConnectOpen(true)}><CloudCog className="text-emerald-300" size={20} />{drive ? 'Drive connected' : 'Connect Google Drive'}</button>
|
||||
<div className="mt-auto rounded-2xl border border-white/10 p-3"><p className="text-xs text-white/40">Signed in as</p><p className="mt-1 text-sm font-bold">{user.name}</p><button className="mt-3 flex items-center gap-2 text-xs font-bold text-white/60" onClick={onLogout}><LogOut size={15} /> Sign out</button></div>
|
||||
</aside>
|
||||
<section className="ml-64 min-h-screen">
|
||||
<header className="sticky top-0 z-10 flex h-20 items-center justify-between border-b border-slate-200 bg-white/90 px-8 backdrop-blur"><div><h1 className="text-xl font-black text-slate-900">Administrator dashboard</h1><p className="mt-0.5 text-xs text-slate-500">Manage Metatron.Drive users and access</p></div><button className="btn-secondary" onClick={() => load(pagination.page)}><RefreshCw size={17} /> Refresh</button></header>
|
||||
<div className="p-8">
|
||||
<Stats stats={stats} />
|
||||
<section className="card mt-7 overflow-hidden">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4 border-b border-slate-200 p-5"><div><h2 className="font-black text-slate-900">Users</h2><p className="mt-1 text-xs text-slate-500">{pagination.total} registered account{pagination.total === 1 ? '' : 's'}</p></div><div className="flex gap-3"><form className="relative" onSubmit={submitSearch}><Search className="absolute left-3.5 top-2.5 text-slate-400" size={18} /><input className="field w-72 py-2 pl-10" placeholder="Search name, username, email…" value={searchInput} onChange={(event) => setSearchInput(event.target.value)} /></form><select className="field w-36 py-2" value={filter} onChange={(event) => setFilter(event.target.value)}><option value="all">All users</option><option value="active">Active</option><option value="suspended">Suspended</option></select></div></div>
|
||||
{error ? <ErrorState message={error} retry={() => load(pagination.page)} /> : loading ? <div className="flex h-72 items-center justify-center"><LoaderCircle className="animate-spin text-blue-600" size={32} /></div> : users.length === 0 ? <div className="flex h-72 flex-col items-center justify-center"><Users className="text-slate-300" size={48} /><h3 className="mt-4 font-bold text-slate-700">No users found</h3></div> : <UserTable users={users} onEdit={setEditing} onToggle={toggleSuspension} onDelete={deleteUser} />}
|
||||
<div className="flex items-center justify-between border-t border-slate-200 px-5 py-4 text-sm"><p className="text-slate-500">Page {pagination.page} of {pagination.pages}</p><div className="flex gap-2"><button className="icon-btn border border-slate-200" disabled={pagination.page <= 1} onClick={() => load(pagination.page - 1)}><ChevronLeft size={18} /></button><button className="icon-btn border border-slate-200" disabled={pagination.page >= pagination.pages} onClick={() => load(pagination.page + 1)}><ChevronRight size={18} /></button></div></div>
|
||||
<section className="ml-72 min-h-screen"><header className="sticky top-0 z-10 flex h-20 items-center justify-between border-b border-slate-200 bg-white/90 px-8 backdrop-blur"><div><h1 className="text-xl font-black">{organization.name}</h1><p className="text-xs text-slate-500">Organization administration</p></div><button className="btn-secondary" onClick={() => load(pagination.page)}><RefreshCw size={17} /> Refresh</button></header>
|
||||
<div className="p-8"><Stats stats={stats} /><DriveNotice drive={drive} open={() => setConnectOpen(true)} />
|
||||
<section className="card mt-7 overflow-hidden"><div className="flex flex-wrap items-center justify-between gap-4 border-b border-slate-200 p-5"><div><h2 className="font-black">Memberships</h2><p className="text-xs text-slate-500">Approve or reject organization access requests.</p></div><div className="flex gap-3"><form className="relative" onSubmit={(event) => { event.preventDefault(); setSearch(searchInput.trim()) }}><Search className="absolute left-3.5 top-2.5 text-slate-400" size={18} /><input className="field w-72 py-2 pl-10" placeholder="Search members…" value={searchInput} onChange={(event) => setSearchInput(event.target.value)} /></form><select className="field w-40 py-2" value={status} onChange={(event) => setStatus(event.target.value)}><option value="ALL">All statuses</option><option value="PENDING">Pending</option><option value="APPROVED">Approved</option><option value="REJECTED">Rejected</option><option value="SUSPENDED">Suspended</option></select></div></div>
|
||||
{error ? <ErrorState message={error} retry={() => load(pagination.page)} /> : loading ? <LoadingRows /> : <MembershipTable rows={rows} approve={(row) => changeMembership(row.id, { status: 'APPROVED' })} reject={reject} suspend={(row) => changeMembership(row.id, { status: row.status === 'SUSPENDED' ? 'APPROVED' : 'SUSPENDED' })} />}
|
||||
<div className="flex items-center justify-between border-t border-slate-200 px-5 py-4 text-sm"><p className="text-slate-500">Page {pagination.page} of {pagination.pages}</p><div className="flex gap-2"><button className="icon-btn border" disabled={pagination.page <= 1} onClick={() => load(pagination.page - 1)}><ChevronLeft size={18} /></button><button className="icon-btn border" disabled={pagination.page >= pagination.pages} onClick={() => load(pagination.page + 1)}><ChevronRight size={18} /></button></div></div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
{editing && <EditUserModal user={editing} onClose={() => setEditing(null)} onSave={saveUser} />}
|
||||
{toast && <Toast toast={toast} />}
|
||||
{connectOpen && <DriveModal drive={drive} close={() => setConnectOpen(false)} connected={() => { setConnectOpen(false); load(pagination.page) }} />}
|
||||
</main>
|
||||
}
|
||||
|
||||
function Stats({ stats }) {
|
||||
const cards = [
|
||||
{ label: 'Total users', value: stats?.users, icon: Users, tone: 'text-blue-600 bg-blue-50' },
|
||||
{ label: 'Active users', value: stats?.activeUsers, icon: UserCheck, tone: 'text-emerald-600 bg-emerald-50' },
|
||||
{ label: 'Suspended', value: stats?.suspendedUsers, icon: Ban, tone: 'text-amber-600 bg-amber-50' },
|
||||
{ label: 'Stored files', value: stats?.files, icon: File, tone: 'text-violet-600 bg-violet-50' },
|
||||
{ label: 'Storage indexed', value: stats ? fileSize(stats.storageBytes) : '—', icon: HardDrive, tone: 'text-cyan-600 bg-cyan-50' },
|
||||
]
|
||||
return <div className="grid grid-cols-5 gap-4">{cards.map(({ label, value, icon: Icon, tone }) => <div className="card p-4" key={label}><span className={`flex h-10 w-10 items-center justify-center rounded-xl ${tone}`}><Icon size={20} /></span><p className="mt-4 text-2xl font-black text-slate-900">{value ?? '—'}</p><p className="mt-1 text-xs font-semibold text-slate-500">{label}</p></div>)}</div>
|
||||
}
|
||||
|
||||
function UserTable({ users, onEdit, onToggle, onDelete }) {
|
||||
return <div className="scrollbar overflow-x-auto"><table className="w-full min-w-[1050px] text-left"><thead className="bg-slate-50 text-[11px] uppercase tracking-wider text-slate-400"><tr><th className="px-5 py-3">User</th><th className="px-5 py-3">Contact</th><th className="px-5 py-3">Content</th><th className="px-5 py-3">Joined</th><th className="px-5 py-3">Status</th><th className="px-5 py-3 text-right">Actions</th></tr></thead><tbody className="divide-y divide-slate-100">{users.map((user) => <tr className="hover:bg-slate-50/70" key={user.id}><td className="px-5 py-4"><div className="flex items-center gap-3"><span className="flex h-10 w-10 items-center justify-center rounded-xl bg-[#101d42] text-xs font-black text-white">{initials(user.name)}</span><div><p className="text-sm font-bold text-slate-800">{user.name}</p><p className="mt-0.5 text-xs text-slate-400">@{user.username}</p></div></div></td><td className="px-5 py-4"><p className="text-sm text-slate-700">{user.email}</p><p className="mt-1 text-xs text-slate-400">{displayPhone(user.phone)}</p></td><td className="px-5 py-4"><div className="flex gap-3 text-xs text-slate-500"><span className="flex items-center gap-1"><Folder size={14} />{user.counts?.folders || 0}</span><span className="flex items-center gap-1"><File size={14} />{user.counts?.files || 0}</span></div></td><td className="px-5 py-4 text-xs text-slate-500">{dateTime(user.createdAt)}</td><td className="px-5 py-4"><span className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-bold ${user.isSuspended ? 'bg-amber-50 text-amber-700' : 'bg-emerald-50 text-emerald-700'}`}>{user.isSuspended ? <Ban size={13} /> : <CheckCircle2 size={13} />}{user.isSuspended ? 'Suspended' : 'Active'}</span></td><td className="px-5 py-4"><div className="flex justify-end gap-1"><button className="icon-btn" title="Edit user" onClick={() => onEdit(user)}><Edit3 size={17} /></button><button className={`icon-btn ${user.isSuspended ? 'text-emerald-600' : 'text-amber-600'}`} title={user.isSuspended ? 'Restore access' : 'Suspend access'} onClick={() => onToggle(user)}>{user.isSuspended ? <UserCheck size={17} /> : <Ban size={17} />}</button><button className="icon-btn text-red-500 hover:text-red-600" title="Delete user" onClick={() => onDelete(user)}><Trash2 size={17} /></button></div></td></tr>)}</tbody></table></div>
|
||||
}
|
||||
|
||||
function EditUserModal({ user, onClose, onSave }) {
|
||||
const [form, setForm] = useState({ name: user.name, username: user.username, email: user.email, phone: user.phone, gender: user.gender, age: String(user.age), createdAt: toLocalInput(user.createdAt), isSuspended: user.isSuspended, password: '' })
|
||||
function DriveModal({ drive, close, connected }) {
|
||||
const [step, setStep] = useState('choose')
|
||||
const [storageType, setStorageType] = useState(drive?.storageType || 'MY_DRIVE')
|
||||
const [sharedDriveId, setSharedDriveId] = useState(drive?.sharedDriveId || '')
|
||||
const [redirectUrl, setRedirectUrl] = useState('')
|
||||
const [state, setState] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
async function submit(event) { event.preventDefault(); setBusy(true); const input = { ...form, age: Number(form.age), createdAt: new Date(form.createdAt).toISOString() }; if (!input.password) delete input.password; await onSave(user.id, input); setBusy(false) }
|
||||
return <div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/45 p-6 backdrop-blur-sm" onMouseDown={onClose}><section className="card max-h-[92vh] w-full max-w-2xl overflow-hidden" onMouseDown={(event) => event.stopPropagation()}><header className="flex items-center justify-between border-b border-slate-200 px-6 py-4"><div><h2 className="text-lg font-black">Edit user</h2><p className="mt-0.5 text-xs text-slate-500">Update account details and access</p></div><button className="icon-btn" onClick={onClose}><X size={20} /></button></header><form className="scrollbar max-h-[calc(92vh-76px)] overflow-y-auto p-6" onSubmit={submit}><div className="grid grid-cols-2 gap-4"><Field label="Full name" value={form.name} set={(value) => setForm({ ...form, name: value })} /><Field label="Username" value={form.username} set={(value) => setForm({ ...form, username: value })} /><Field label="Email address" type="email" value={form.email} set={(value) => setForm({ ...form, email: value })} /><Field label="Phone number" value={form.phone} set={(value) => setForm({ ...form, phone: value })} /><label><span className="mb-1.5 block text-xs font-bold text-slate-500">Gender</span><select className="field" value={form.gender} onChange={(event) => setForm({ ...form, gender: event.target.value })}><option>Male</option><option>Female</option><option>Non-binary</option><option>Prefer not to say</option></select></label><Field label="Age" type="number" min="13" max="120" value={form.age} set={(value) => setForm({ ...form, age: value })} /><Field label="Created date and time" type="datetime-local" value={form.createdAt} set={(value) => setForm({ ...form, createdAt: value })} /><Field label="New password (leave blank to keep)" type="password" value={form.password} set={(value) => setForm({ ...form, password: value })} /><label className="col-span-2 flex items-center justify-between rounded-xl border border-slate-200 p-4"><span><span className="block text-sm font-bold text-slate-700">Suspend account</span><span className="mt-1 block text-xs text-slate-400">The user cannot log in or use an existing session.</span></span><input className="h-5 w-5 accent-blue-600" type="checkbox" checked={form.isSuspended} onChange={(event) => setForm({ ...form, isSuspended: event.target.checked })} /></label></div><div className="mt-6 flex justify-end gap-3"><button type="button" className="btn-secondary" onClick={onClose}>Cancel</button><button className="btn-primary" disabled={busy}>{busy ? <LoaderCircle className="animate-spin" size={18} /> : <><Edit3 size={17} /> Save changes</>}</button></div></form></section></div>
|
||||
const [error, setError] = useState('')
|
||||
|
||||
async function begin() {
|
||||
setBusy(true); setError('')
|
||||
try { const result = await adminApi.authorizationUrl(); setState(result.state); window.open(result.authorizationUrl, '_blank', 'noopener,noreferrer'); setStep('paste') }
|
||||
catch (requestError) { setError(messageFrom(requestError)) }
|
||||
finally { setBusy(false) }
|
||||
}
|
||||
async function finish(event) {
|
||||
event.preventDefault(); setBusy(true); setError('')
|
||||
try { await adminApi.exchangeDrive({ redirectUrlOrCode: redirectUrl, state, storageType, sharedDriveId: storageType === 'SHARED_DRIVE' ? sharedDriveId : undefined }); connected() }
|
||||
catch (requestError) { setError(messageFrom(requestError)) }
|
||||
finally { setBusy(false) }
|
||||
}
|
||||
return <div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/50 p-6" onMouseDown={close}><section className="card w-full max-w-xl p-6" onMouseDown={(event) => event.stopPropagation()}><h2 className="text-xl font-black">Connect Google Drive</h2>{drive && <p className="mt-2 text-sm text-emerald-700">Currently connected to {drive.storageType === 'MY_DRIVE' ? 'My Drive' : 'a Shared Drive'}.</p>}
|
||||
{step === 'choose' ? <><div className="mt-6 grid grid-cols-2 gap-3"><Choice active={storageType === 'MY_DRIVE'} title="My Drive" text="Store under the approving Google account." choose={() => setStorageType('MY_DRIVE')} /><Choice active={storageType === 'SHARED_DRIVE'} title="Shared Drive" text="Store under a Google Workspace Shared Drive." choose={() => setStorageType('SHARED_DRIVE')} /></div>{storageType === 'SHARED_DRIVE' && <div className="mt-4"><Input label="Shared Drive ID" value={sharedDriveId} set={setSharedDriveId} /></div>}<p className="mt-5 text-sm leading-6 text-slate-500">Testing uses the desktop OAuth client. Google will redirect to localhost; that page may not load. Copy the complete URL from the browser address bar.</p>{error && <ErrorBox text={error} />}<div className="mt-6 flex justify-end gap-3"><button className="btn-secondary" onClick={close}>Cancel</button><button className="btn-primary" disabled={busy || (storageType === 'SHARED_DRIVE' && !sharedDriveId.trim())} onClick={begin}>{busy ? <LoaderCircle className="animate-spin" size={18} /> : <><ExternalLink size={17} /> Authorize with Google</>}</button></div></> : <form onSubmit={finish}><p className="mt-5 text-sm leading-6 text-slate-600">After approving access, copy the complete <strong>http://localhost/?code=…&state=…</strong> URL and paste it below.</p><textarea className="field mt-4 min-h-32" value={redirectUrl} onChange={(event) => setRedirectUrl(event.target.value)} placeholder="Paste the localhost redirect URL" autoFocus />{error && <ErrorBox text={error} />}<div className="mt-6 flex justify-end gap-3"><button type="button" className="btn-secondary" onClick={() => setStep('choose')}>Back</button><button className="btn-primary" disabled={busy || !redirectUrl.trim()}>{busy ? <LoaderCircle className="animate-spin" size={18} /> : 'Complete connection'}</button></div></form>}
|
||||
</section></div>
|
||||
}
|
||||
|
||||
function Field({ label, type = 'text', value, set, ...props }) { return <label><span className="mb-1.5 block text-xs font-bold text-slate-500">{label}</span><input className="field" type={type} value={value} onChange={(event) => set(event.target.value)} required={!label.includes('leave blank')} {...props} /></label> }
|
||||
function ErrorState({ message, retry }) { return <div className="flex h-72 flex-col items-center justify-center"><XCircle className="text-red-500" size={40} /><p className="mt-4 text-sm font-bold text-slate-700">{message}</p><button className="btn-secondary mt-4" onClick={retry}><RefreshCw size={17} /> Retry</button></div> }
|
||||
function Toast({ toast }) { return <div className={`fixed right-6 top-6 z-[60] flex max-w-md items-center gap-3 rounded-xl border bg-white px-4 py-3 text-sm font-bold shadow-xl ${toast.type === 'error' ? 'border-red-200 text-red-700' : 'border-emerald-200 text-emerald-700'}`}>{toast.type === 'error' ? <XCircle size={18} /> : <CheckCircle2 size={18} />}{toast.text}</div> }
|
||||
function initials(name = '') { return name.split(/\s+/).filter(Boolean).slice(0, 2).map((part) => part[0].toUpperCase()).join('') || 'U' }
|
||||
function displayPhone(phone = '') { return phone.startsWith('wd') ? 'Not provided' : phone }
|
||||
function dateTime(value) { return new Intl.DateTimeFormat('en-IN', { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(value)) }
|
||||
function toLocalInput(value) { const date = new Date(value); const offset = date.getTimezoneOffset() * 60000; return new Date(date.getTime() - offset).toISOString().slice(0, 16) }
|
||||
function fileSize(bytes = 0) { const value = Number(bytes); if (value < 1024 ** 2) return `${(value / 1024).toFixed(1)} KB`; if (value < 1024 ** 3) return `${(value / 1024 ** 2).toFixed(1)} MB`; return `${(value / 1024 ** 3).toFixed(2)} GB` }
|
||||
function MembershipTable({ rows, approve, reject, suspend }) {
|
||||
if (!rows.length) return <div className="flex h-64 items-center justify-center text-slate-500">No memberships found.</div>
|
||||
return <div className="overflow-x-auto"><table className="w-full min-w-[900px] text-left"><thead className="bg-slate-50 text-[11px] uppercase text-slate-400"><tr><th className="px-5 py-3">Member</th><th className="px-5 py-3">Contact</th><th className="px-5 py-3">Content</th><th className="px-5 py-3">Role</th><th className="px-5 py-3">Status</th><th className="px-5 py-3 text-right">Actions</th></tr></thead><tbody className="divide-y divide-slate-100">{rows.map((row) => <tr key={row.id}><td className="px-5 py-4"><p className="font-bold">{row.user.name}</p><p className="text-xs text-slate-400">@{row.user.username}</p></td><td className="px-5 py-4 text-sm"><p>{row.user.email}</p><p className="text-xs text-slate-400">{row.user.phone}</p></td><td className="px-5 py-4 text-xs text-slate-500">{row.counts?.folders || 0} folders · {row.counts?.files || 0} files</td><td className="px-5 py-4 text-xs font-bold">{row.role}</td><td className="px-5 py-4"><Status value={row.status} />{row.rejectionReason && <p className="mt-1 max-w-52 text-xs text-red-500">{row.rejectionReason}</p>}</td><td className="px-5 py-4"><div className="flex justify-end gap-1">{row.status !== 'APPROVED' && row.role !== 'OWNER' && <button className="icon-btn text-emerald-600" title="Approve" onClick={() => approve(row)}><UserCheck size={18} /></button>}{row.status === 'PENDING' && <button className="icon-btn text-red-600" title="Reject" onClick={() => reject(row)}><XCircle size={18} /></button>}{row.status === 'APPROVED' && row.role !== 'OWNER' && <button className="icon-btn text-amber-600" title="Suspend" onClick={() => suspend(row)}><Ban size={18} /></button>}{row.status === 'SUSPENDED' && <button className="icon-btn text-emerald-600" title="Restore" onClick={() => suspend(row)}><UserCheck size={18} /></button>}</div></td></tr>)}</tbody></table></div>
|
||||
}
|
||||
|
||||
function Stats({ stats }) { const cards = [{ label: 'Members', value: stats?.members, icon: Users }, { label: 'Pending', value: stats?.pending, icon: UserPlus }, { label: 'Approved', value: stats?.approved, icon: UserCheck }, { label: 'Files', value: stats?.files, icon: File }, { label: 'Folders', value: stats?.folders, icon: Folder }]; return <div className="grid grid-cols-5 gap-4">{cards.map(({ label, value, icon: Icon }) => <div className="card p-4" key={label}><Icon className="text-blue-600" size={21} /><p className="mt-4 text-2xl font-black">{value ?? '—'}</p><p className="text-xs text-slate-500">{label}</p></div>)}</div> }
|
||||
function DriveNotice({ drive, open }) { return <div className={`mt-6 flex items-center justify-between rounded-2xl border p-5 ${drive ? 'border-emerald-200 bg-emerald-50' : 'border-amber-200 bg-amber-50'}`}><div className="flex items-center gap-4"><HardDrive className={drive ? 'text-emerald-600' : 'text-amber-600'} /><div><p className="font-black">{drive ? 'Google Drive connected' : 'Google Drive connection required'}</p><p className="text-sm text-slate-600">{drive ? `${drive.storageType === 'MY_DRIVE' ? 'My Drive' : 'Shared Drive'} is ready for organization files.` : 'Members cannot create folders or upload until Drive is connected.'}</p></div></div><button className="btn-secondary" onClick={open}>{drive ? 'Reconnect' : 'Connect'}</button></div> }
|
||||
function Choice({ active, title, text, choose }) { return <button type="button" className={`rounded-xl border p-4 text-left ${active ? 'border-blue-500 bg-blue-50' : 'border-slate-200'}`} onClick={choose}><p className="font-black">{title}</p><p className="mt-1 text-xs text-slate-500">{text}</p></button> }
|
||||
function Status({ value }) { const tone = { APPROVED: 'bg-emerald-50 text-emerald-700', PENDING: 'bg-blue-50 text-blue-700', REJECTED: 'bg-red-50 text-red-700', SUSPENDED: 'bg-amber-50 text-amber-700' }; return <span className={`rounded-full px-2.5 py-1 text-xs font-bold ${tone[value] || 'bg-slate-100'}`}>{value}</span> }
|
||||
function Input({ label, value, set, type = 'text' }) { return <label><span className="mb-1.5 block text-xs font-bold text-slate-500">{label}</span><input className="field" type={type} value={value} onChange={(event) => set(event.target.value)} required /></label> }
|
||||
function ErrorBox({ text }) { return <div className="mt-5 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm font-semibold text-red-700">{text}</div> }
|
||||
function ErrorState({ message, retry }) { return <div className="flex h-64 flex-col items-center justify-center"><XCircle className="text-red-500" size={38} /><p className="mt-3 text-sm font-bold">{message}</p><button className="btn-secondary mt-4" onClick={retry}><RefreshCw size={17} /> Retry</button></div> }
|
||||
function LoadingRows() { return <div className="flex h-64 items-center justify-center"><LoaderCircle className="animate-spin text-blue-600" size={32} /></div> }
|
||||
|
||||
22
src/api.js
22
src/api.js
@ -2,12 +2,14 @@ import axios from 'axios'
|
||||
|
||||
const client = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL || 'https://filedriveapi.thedomainnest.com/api',
|
||||
timeout: 30000,
|
||||
timeout: 5 * 60 * 1000,
|
||||
})
|
||||
|
||||
client.interceptors.request.use((config) => {
|
||||
const token = sessionStorage.getItem('metatron-admin-token')
|
||||
const token = sessionStorage.getItem('metatron-org-admin-token')
|
||||
const organizationId = sessionStorage.getItem('metatron-org-admin-organization')
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`
|
||||
if (organizationId) config.headers['X-Organization-Id'] = organizationId
|
||||
return config
|
||||
})
|
||||
|
||||
@ -16,10 +18,14 @@ export function messageFrom(error) {
|
||||
}
|
||||
|
||||
export const adminApi = {
|
||||
async login(username, password) { return (await client.post('/admin/login', { username, password })).data },
|
||||
async me() { return (await client.get('/admin/me')).data.admin },
|
||||
async stats() { return (await client.get('/admin/stats')).data.stats },
|
||||
async users(params) { return (await client.get('/admin/users', { params })).data },
|
||||
async updateUser(id, input) { return (await client.patch(`/admin/users/${id}`, input)).data.user },
|
||||
deleteUser(id) { return client.delete(`/admin/users/${id}`) },
|
||||
async login(username, password) { return (await client.post('/auth/login', { username, password })).data },
|
||||
async me() { return (await client.get('/auth/me')).data.user },
|
||||
async registerOrganization(input) { return (await client.post('/organizations/register', input)).data },
|
||||
async currentOrganization() { return (await client.get('/organizations/current')).data },
|
||||
async drive() { return (await client.get('/organizations/drive')).data.connection },
|
||||
async authorizationUrl() { return (await client.post('/organizations/drive/authorization-url')).data },
|
||||
async exchangeDrive(input) { return (await client.post('/organizations/drive/exchange', input)).data.connection },
|
||||
async stats() { return (await client.get('/org-admin/stats')).data.stats },
|
||||
async memberships(params) { return (await client.get('/org-admin/memberships', { params })).data },
|
||||
async updateMembership(id, input) { return (await client.patch(`/org-admin/memberships/${id}`, input)).data.membership },
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user