feat: enhance organization management and user authentication flow

This commit is contained in:
MOHAN 2026-07-06 15:37:19 +05:30
parent bdea5a4a96
commit 9ed4d6c429
6 changed files with 87 additions and 21 deletions

View File

@ -2,6 +2,8 @@
Windows desktop client for the Metatron.Drive private file workspace.
Users sign in once, can hold multiple approved organization memberships, submit additional join-code requests, and switch the active organization locally.
## Stack
- Electron

View File

@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'
import { useCallback, useEffect, useState } from 'react'
import { api } from './lib/api.js'
import Splash from './components/Splash.jsx'
import AuthScreen from './components/AuthScreen.jsx'
@ -7,6 +7,16 @@ import DriveApp from './components/DriveApp.jsx'
export default function App() {
const [status, setStatus] = useState('loading')
const [user, setUser] = useState(null)
const [organizationId, setOrganizationId] = useState(() => localStorage.getItem('metatron.drive-organization'))
const applyProfile = useCallback((profile) => {
const approved = (profile.memberships || []).filter((item) => item.status === 'APPROVED' && item.organization?.status === 'ACTIVE')
const selected = approved.find((item) => item.organization.id === organizationId) || approved[0]
if (!selected) throw new Error('No approved organization membership is available.')
localStorage.setItem('metatron.drive-organization', selected.organization.id)
setOrganizationId(selected.organization.id)
setUser(profile)
}, [organizationId])
useEffect(() => {
let active = true
@ -16,14 +26,14 @@ export default function App() {
if (!token) return setStatus('signedOut')
try {
const profile = await api.me()
if (active) { setUser(profile); setStatus('signedIn') }
if (active) { applyProfile(profile); setStatus('signedIn') }
} catch {
await window.desktop.session.clear()
if (active) setStatus('signedOut')
}
})
return () => { active = false }
}, [])
}, [applyProfile])
async function logout() {
await window.desktop.session.clear()
@ -31,7 +41,17 @@ export default function App() {
setStatus('signedOut')
}
async function refreshUser() {
const profile = await api.me()
applyProfile(profile)
}
function selectOrganization(id) {
localStorage.setItem('metatron.drive-organization', id)
setOrganizationId(id)
}
if (status === 'loading') return <Splash />
if (status === 'signedOut') return <AuthScreen onAuthenticated={(profile) => { setUser(profile); setStatus('signedIn') }} />
return <DriveApp user={user} onLogout={logout} />
if (status === 'signedOut') return <AuthScreen onAuthenticated={(profile) => { applyProfile(profile); setStatus('signedIn') }} />
return <DriveApp key={organizationId} user={user} organizationId={organizationId} onSelectOrganization={selectOrganization} onRefreshUser={refreshUser} onLogout={logout} />
}

View File

@ -2,7 +2,7 @@ import { useState } from 'react'
import { ArrowLeft, Cloud, Eye, EyeOff, LockKeyhole, Mail, Phone, User, UserRound } from 'lucide-react'
import { api, errorMessage } from '../lib/api.js'
const emptyRegister = { name: '', username: '', email: '', phone: '', password: '' }
const emptyRegister = { organizationCode: '', name: '', username: '', email: '', phone: '', password: '' }
export default function AuthScreen({ onAuthenticated }) {
const [mode, setMode] = useState('login')
@ -11,12 +11,14 @@ export default function AuthScreen({ onAuthenticated }) {
const [hidden, setHidden] = useState(true)
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const [notice, setNotice] = useState('')
async function submitLogin(event) {
event.preventDefault()
if (login.username.trim().length < 3 || !login.password) return setError('Enter your username and password.')
setBusy(true)
setError('')
setNotice('')
try {
const result = await api.login(login.username.trim(), login.password)
await window.desktop.session.write(result.token)
@ -31,6 +33,7 @@ export default function AuthScreen({ onAuthenticated }) {
async function submitRegister(event) {
event.preventDefault()
const username = register.username.trim().toLowerCase()
if (register.organizationCode.trim().length < 4) return setError('Enter your organization code.')
if (register.name.trim().length < 2) return setError('Enter your full name.')
if (!/^[a-z0-9._-]{3,30}$/.test(username)) return setError('Enter a valid username.')
if (!/^.+@.+\..+$/.test(register.email.trim())) return setError('Enter a valid email address.')
@ -40,6 +43,7 @@ export default function AuthScreen({ onAuthenticated }) {
setError('')
try {
const result = await api.register({
organizationCode: register.organizationCode.trim().toUpperCase(),
name: register.name.trim(),
username,
email: register.email.trim().toLowerCase(),
@ -48,8 +52,8 @@ export default function AuthScreen({ onAuthenticated }) {
gender: 'Prefer not to say',
password: register.password,
})
await window.desktop.session.write(result.token)
onAuthenticated(result.user)
setNotice(result.message)
setRegister(emptyRegister)
} catch (requestError) {
setError(errorMessage(requestError))
} finally {
@ -85,7 +89,7 @@ export default function AuthScreen({ onAuthenticated }) {
<Field icon={UserRound} label="Username" value={login.username} onChange={(value) => setLogin({ ...login, username: value })} autoFocus />
<PasswordField value={login.password} onChange={(value) => setLogin({ ...login, password: value })} hidden={hidden} setHidden={setHidden} />
</div>
{error && <ErrorBox message={error} />}
{error && <ErrorBox message={error} />}{notice && <NoticeBox message={notice} />}
<button className="btn-primary mt-6 w-full" disabled={busy}>{busy ? <Spinner /> : 'Sign in'}</button>
<button type="button" className="mt-5 w-full text-sm font-bold text-blue-600 hover:text-blue-700" onClick={() => { setError(''); setMode('register') }}>New to the family? Create an account</button>
</form>
@ -95,13 +99,14 @@ export default function AuthScreen({ onAuthenticated }) {
<h2 className="text-3xl font-black text-slate-900">Join the digital family</h2>
<p className="mt-2 text-slate-500">We create your private Google Drive folder automatically.</p>
<div className="mt-7 grid grid-cols-2 gap-4">
<div className="col-span-2"><Field icon={User} label="Full name" value={register.name} onChange={(value) => setRegister({ ...register, name: value })} autoFocus /></div>
<div className="col-span-2"><Field icon={Cloud} label="Organization code" value={register.organizationCode} onChange={(value) => setRegister({ ...register, organizationCode: value.toUpperCase() })} autoFocus /></div>
<div className="col-span-2"><Field icon={User} label="Full name" value={register.name} onChange={(value) => setRegister({ ...register, name: value })} /></div>
<Field icon={UserRound} label="Username" value={register.username} onChange={(value) => setRegister({ ...register, username: value })} />
<Field icon={Mail} type="email" label="Email address" value={register.email} onChange={(value) => setRegister({ ...register, email: value })} />
<div className="col-span-2"><Field icon={Phone} label="Phone number (optional)" value={register.phone} onChange={(value) => setRegister({ ...register, phone: value })} /></div>
<div className="col-span-2"><PasswordField value={register.password} onChange={(value) => setRegister({ ...register, password: value })} hidden={hidden} setHidden={setHidden} /></div>
</div>
{error && <ErrorBox message={error} />}
{error && <ErrorBox message={error} />}{notice && <NoticeBox message={notice} />}
<button className="btn-primary mt-6 w-full" disabled={busy}>{busy ? <Spinner /> : 'Create Metatron.Drive'}</button>
</form>
)}
@ -122,6 +127,10 @@ function ErrorBox({ message }) {
return <div className="mt-5 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm font-medium text-red-700">{message}</div>
}
function NoticeBox({ message }) {
return <div className="mt-5 rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-medium text-emerald-700">{message}</div>
}
function Spinner() {
return <span className="h-5 w-5 animate-spin rounded-full border-2 border-white/30 border-t-white" />
}

View File

@ -11,7 +11,7 @@ import { useUploads } from '../hooks/useUploads.js'
import Modal from './Modal.jsx'
import UploadHistory from './UploadHistory.jsx'
export default function DriveApp({ user, onLogout }) {
export default function DriveApp({ user, organizationId, onSelectOrganization, onRefreshUser, onLogout }) {
const [path, setPath] = useState([])
const [folders, setFolders] = useState([])
const [files, setFiles] = useState([])
@ -23,6 +23,8 @@ export default function DriveApp({ user, onLogout }) {
const [toast, setToast] = useState(null)
const inputRef = useRef(null)
const folderId = path.at(-1)?.id || null
const memberships = (user.memberships || []).filter((item) => item.status === 'APPROVED' && item.organization?.status === 'ACTIVE')
const selectedMembership = memberships.find((item) => item.organization.id === organizationId) || memberships[0]
const load = useCallback(async () => {
setLoading(true)
@ -51,7 +53,7 @@ export default function DriveApp({ user, onLogout }) {
const onUploadComplete = useCallback((destinationId) => {
if ((destinationId || null) === folderId) load()
}, [folderId, load])
const uploadState = useUploads(onUploadComplete)
const uploadState = useUploads(onUploadComplete, organizationId)
async function createFolder(name) {
try {
@ -110,13 +112,13 @@ export default function DriveApp({ user, onLogout }) {
return (
<main className="flex h-screen bg-[#f6f8fc]">
<Sidebar user={user} view={view} setView={setView} activeUploads={uploadState.active.length} onProfile={() => setModal('profile')} />
<Sidebar user={user} memberships={memberships} organizationId={organizationId} onSelectOrganization={onSelectOrganization} view={view} setView={setView} activeUploads={uploadState.active.length} onJoin={() => setModal('join')} onProfile={() => setModal('profile')} />
<section className="flex min-w-0 flex-1 flex-col">
<header className="flex h-[82px] shrink-0 items-center gap-5 border-b border-slate-200 bg-white px-7">
{view === 'drive' && path.length > 0 && <button className="icon-btn" onClick={() => setPath((current) => current.slice(0, -1))}><ArrowLeft size={21} /></button>}
<div className="min-w-0 flex-1">
<h1 className="truncate text-xl font-black text-slate-900">{view === 'history' ? 'Upload history' : path.at(-1)?.name || 'Metatron.Drive'}</h1>
<p className="mt-0.5 text-xs text-slate-500">{view === 'history' ? 'Track every desktop upload' : `Hello, ${user.name.split(' ')[0]}`}</p>
<h1 className="truncate text-xl font-black text-slate-900">{view === 'history' ? 'Upload history' : path.at(-1)?.name || selectedMembership?.organization.name || 'Metatron.Drive'}</h1>
<p className="mt-0.5 text-xs text-slate-500">{view === 'history' ? `Uploads for ${selectedMembership?.organization.name}` : `Hello, ${user.name.split(' ')[0]} · ${selectedMembership?.organization.code}`}</p>
</div>
{view === 'drive' && <>
<label className="relative w-72"><Search className="absolute left-3.5 top-2.5 text-slate-400" size={18} /><input className="field py-2 pl-10" placeholder="Search this folder" value={search} onChange={(event) => setSearch(event.target.value)} /></label>
@ -155,19 +157,23 @@ export default function DriveApp({ user, onLogout }) {
{uploadState.active.length > 0 && <ActiveUploadBar items={uploadState.active} onOpen={() => setView('history')} onCancel={uploadState.cancel} />}
{toast && <Toast toast={toast} />}
{modal === 'folder' && <NewFolderModal onClose={() => setModal(null)} onCreate={createFolder} />}
{modal === 'join' && <JoinOrganizationModal onClose={() => setModal(null)} onJoined={async (message) => { await onRefreshUser(); setModal(null); setToast({ type: 'success', message }) }} />}
{modal === 'profile' && <ProfileModal user={user} onClose={() => setModal(null)} onLogout={onLogout} onDeleted={onLogout} setToast={setToast} />}
{modal?.type === 'preview' && <PreviewModal item={modal.item} onClose={() => setModal(null)} onDownload={() => downloadFile(modal.item)} />}
</main>
)
}
function Sidebar({ user, view, setView, activeUploads, onProfile }) {
function Sidebar({ user, memberships, organizationId, onSelectOrganization, view, setView, activeUploads, onJoin, onProfile }) {
return <aside className="flex w-64 shrink-0 flex-col border-r border-slate-200 bg-white px-4 py-5">
<div className="flex items-center gap-3 px-3 text-lg font-black"><span className="flex h-10 w-10 items-center justify-center rounded-2xl bg-blue-600 text-white"><Cloud size={23} fill="currentColor" /></span>Metatron.Drive</div>
<nav className="mt-9 space-y-1">
<NavButton active={view === 'drive'} icon={HardDrive} label="My Drive" onClick={() => setView('drive')} />
<NavButton active={view === 'history'} icon={History} label="Upload history" badge={activeUploads || null} onClick={() => setView('history')} />
</nav>
<label className="mt-7 px-3 text-[10px] font-black uppercase tracking-wider text-slate-400">Organization</label>
<select className="field mt-2 py-2 text-sm font-bold" value={organizationId || ''} onChange={(event) => onSelectOrganization(event.target.value)}>{memberships.map((item) => <option key={item.organization.id} value={item.organization.id}>{item.organization.name}</option>)}</select>
<button className="mt-2 px-3 text-left text-xs font-bold text-blue-600" onClick={onJoin}>+ Join another organization</button>
<div className="mt-8 rounded-2xl bg-[#101d42] p-4 text-white">
<ShieldCheck className="text-emerald-300" size={23} />
<p className="mt-3 text-sm font-bold">Private workspace</p>
@ -250,6 +256,25 @@ function NewFolderModal({ onClose, onCreate }) {
return <Modal title="New folder" onClose={onClose}><form onSubmit={(event) => { event.preventDefault(); if (name.trim()) onCreate(name.trim()) }}><label className="text-sm font-bold text-slate-600">Folder name</label><input className="field mt-2" value={name} onChange={(event) => setName(event.target.value)} autoFocus maxLength={100} /><div className="mt-6 flex justify-end gap-3"><button type="button" className="btn-secondary" onClick={onClose}>Cancel</button><button className="btn-primary py-2.5" disabled={!name.trim()}><FolderPlus size={18} /> Create folder</button></div></form></Modal>
}
function JoinOrganizationModal({ onClose, onJoined }) {
const [code, setCode] = useState('')
const [organization, setOrganization] = useState(null)
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
async function find(event) {
event.preventDefault(); setBusy(true); setError('')
try { setOrganization(await api.lookupOrganization(code.trim().toUpperCase())) }
catch (requestError) { setError(errorMessage(requestError)); setOrganization(null) }
finally { setBusy(false) }
}
async function join() {
setBusy(true); setError('')
try { const result = await api.joinOrganization(code.trim().toUpperCase()); await onJoined(result.message) }
catch (requestError) { setError(errorMessage(requestError)); setBusy(false) }
}
return <Modal title="Join an organization" onClose={onClose}><form onSubmit={find}><label className="text-sm font-bold text-slate-600">Organization code</label><input className="field mt-2 uppercase" value={code} onChange={(event) => { setCode(event.target.value.toUpperCase()); setOrganization(null) }} autoFocus /><button className="btn-secondary mt-3 w-full" disabled={busy || code.trim().length < 4}>{busy ? 'Checking…' : 'Find organization'}</button></form>{organization && <div className="mt-5 rounded-xl border border-blue-200 bg-blue-50 p-4"><p className="font-black text-slate-800">{organization.name}</p><p className="mt-1 text-xs text-slate-500">Code: {organization.code}</p><button className="btn-primary mt-4 w-full" disabled={busy} onClick={join}>Request to join</button></div>}{error && <p className="mt-4 text-sm font-bold text-red-600">{error}</p>}</Modal>
}
function ProfileModal({ user, onClose, onLogout, onDeleted, setToast }) {
const [deleting, setDeleting] = useState(false)
const [password, setPassword] = useState('')

View File

@ -12,7 +12,7 @@ function readHistory() {
}
}
export function useUploads(onComplete) {
export function useUploads(onComplete, organizationId) {
const [uploads, setUploadsState] = useState(readHistory)
const fileRefs = useRef(new Map())
const abortRefs = useRef(new Map())
@ -63,6 +63,7 @@ export function useUploads(onComplete) {
size: file.size,
type: file.type || 'application/octet-stream',
folderId: folderId || null,
organizationId,
createdAt: new Date().toISOString(),
status: 'queued',
progress: 0,
@ -72,7 +73,7 @@ export function useUploads(onComplete) {
setUploads((current) => [entry, ...current])
run(entry, file)
}
}, [run, setUploads])
}, [organizationId, run, setUploads])
const cancel = useCallback((id) => abortRefs.current.get(id)?.abort(), [])
@ -97,6 +98,7 @@ export function useUploads(onComplete) {
setUploads((current) => current.filter((item) => !finalStatuses.has(item.status)))
}, [setUploads])
const active = useMemo(() => uploads.filter((item) => !finalStatuses.has(item.status)), [uploads])
return { uploads, active, enqueue, cancel, retry, remove, clearFinished }
const visibleUploads = useMemo(() => uploads.filter((item) => !item.organizationId || item.organizationId === organizationId), [organizationId, uploads])
const active = useMemo(() => visibleUploads.filter((item) => !finalStatuses.has(item.status)), [visibleUploads])
return { uploads: visibleUploads, active, enqueue, cancel, retry, remove, clearFinished }
}

View File

@ -10,6 +10,8 @@ const client = axios.create({
client.interceptors.request.use(async (config) => {
const token = await window.desktop.session.read()
if (token) config.headers.Authorization = `Bearer ${token}`
const organizationId = localStorage.getItem('metatron.drive-organization')
if (organizationId) config.headers['X-Organization-Id'] = organizationId
return config
})
@ -24,6 +26,12 @@ export const api = {
async register(input) {
return (await client.post('/auth/register', input)).data
},
async joinOrganization(organizationCode) {
return (await client.post('/auth/join', { organizationCode })).data
},
async lookupOrganization(code) {
return (await client.get(`/organizations/lookup/${encodeURIComponent(code)}`)).data.organization
},
async me() {
return (await client.get('/auth/me')).data.user
},