feat: enhance organization management and user authentication flow
This commit is contained in:
parent
bdea5a4a96
commit
9ed4d6c429
@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
Windows desktop client for the Metatron.Drive private file workspace.
|
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
|
## Stack
|
||||||
|
|
||||||
- Electron
|
- Electron
|
||||||
|
|||||||
32
src/App.jsx
32
src/App.jsx
@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { api } from './lib/api.js'
|
import { api } from './lib/api.js'
|
||||||
import Splash from './components/Splash.jsx'
|
import Splash from './components/Splash.jsx'
|
||||||
import AuthScreen from './components/AuthScreen.jsx'
|
import AuthScreen from './components/AuthScreen.jsx'
|
||||||
@ -7,6 +7,16 @@ import DriveApp from './components/DriveApp.jsx'
|
|||||||
export default function App() {
|
export default function App() {
|
||||||
const [status, setStatus] = useState('loading')
|
const [status, setStatus] = useState('loading')
|
||||||
const [user, setUser] = useState(null)
|
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(() => {
|
useEffect(() => {
|
||||||
let active = true
|
let active = true
|
||||||
@ -16,14 +26,14 @@ export default function App() {
|
|||||||
if (!token) return setStatus('signedOut')
|
if (!token) return setStatus('signedOut')
|
||||||
try {
|
try {
|
||||||
const profile = await api.me()
|
const profile = await api.me()
|
||||||
if (active) { setUser(profile); setStatus('signedIn') }
|
if (active) { applyProfile(profile); setStatus('signedIn') }
|
||||||
} catch {
|
} catch {
|
||||||
await window.desktop.session.clear()
|
await window.desktop.session.clear()
|
||||||
if (active) setStatus('signedOut')
|
if (active) setStatus('signedOut')
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return () => { active = false }
|
return () => { active = false }
|
||||||
}, [])
|
}, [applyProfile])
|
||||||
|
|
||||||
async function logout() {
|
async function logout() {
|
||||||
await window.desktop.session.clear()
|
await window.desktop.session.clear()
|
||||||
@ -31,7 +41,17 @@ export default function App() {
|
|||||||
setStatus('signedOut')
|
setStatus('signedOut')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status === 'loading') return <Splash />
|
async function refreshUser() {
|
||||||
if (status === 'signedOut') return <AuthScreen onAuthenticated={(profile) => { setUser(profile); setStatus('signedIn') }} />
|
const profile = await api.me()
|
||||||
return <DriveApp user={user} onLogout={logout} />
|
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) => { applyProfile(profile); setStatus('signedIn') }} />
|
||||||
|
return <DriveApp key={organizationId} user={user} organizationId={organizationId} onSelectOrganization={selectOrganization} onRefreshUser={refreshUser} onLogout={logout} />
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { useState } from 'react'
|
|||||||
import { ArrowLeft, Cloud, Eye, EyeOff, LockKeyhole, Mail, Phone, User, UserRound } from 'lucide-react'
|
import { ArrowLeft, Cloud, Eye, EyeOff, LockKeyhole, Mail, Phone, User, UserRound } from 'lucide-react'
|
||||||
import { api, errorMessage } from '../lib/api.js'
|
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 }) {
|
export default function AuthScreen({ onAuthenticated }) {
|
||||||
const [mode, setMode] = useState('login')
|
const [mode, setMode] = useState('login')
|
||||||
@ -11,12 +11,14 @@ export default function AuthScreen({ onAuthenticated }) {
|
|||||||
const [hidden, setHidden] = useState(true)
|
const [hidden, setHidden] = useState(true)
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
const [notice, setNotice] = useState('')
|
||||||
|
|
||||||
async function submitLogin(event) {
|
async function submitLogin(event) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
if (login.username.trim().length < 3 || !login.password) return setError('Enter your username and password.')
|
if (login.username.trim().length < 3 || !login.password) return setError('Enter your username and password.')
|
||||||
setBusy(true)
|
setBusy(true)
|
||||||
setError('')
|
setError('')
|
||||||
|
setNotice('')
|
||||||
try {
|
try {
|
||||||
const result = await api.login(login.username.trim(), login.password)
|
const result = await api.login(login.username.trim(), login.password)
|
||||||
await window.desktop.session.write(result.token)
|
await window.desktop.session.write(result.token)
|
||||||
@ -31,6 +33,7 @@ export default function AuthScreen({ onAuthenticated }) {
|
|||||||
async function submitRegister(event) {
|
async function submitRegister(event) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
const username = register.username.trim().toLowerCase()
|
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 (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 (!/^[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.')
|
if (!/^.+@.+\..+$/.test(register.email.trim())) return setError('Enter a valid email address.')
|
||||||
@ -40,6 +43,7 @@ export default function AuthScreen({ onAuthenticated }) {
|
|||||||
setError('')
|
setError('')
|
||||||
try {
|
try {
|
||||||
const result = await api.register({
|
const result = await api.register({
|
||||||
|
organizationCode: register.organizationCode.trim().toUpperCase(),
|
||||||
name: register.name.trim(),
|
name: register.name.trim(),
|
||||||
username,
|
username,
|
||||||
email: register.email.trim().toLowerCase(),
|
email: register.email.trim().toLowerCase(),
|
||||||
@ -48,8 +52,8 @@ export default function AuthScreen({ onAuthenticated }) {
|
|||||||
gender: 'Prefer not to say',
|
gender: 'Prefer not to say',
|
||||||
password: register.password,
|
password: register.password,
|
||||||
})
|
})
|
||||||
await window.desktop.session.write(result.token)
|
setNotice(result.message)
|
||||||
onAuthenticated(result.user)
|
setRegister(emptyRegister)
|
||||||
} catch (requestError) {
|
} catch (requestError) {
|
||||||
setError(errorMessage(requestError))
|
setError(errorMessage(requestError))
|
||||||
} finally {
|
} 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 />
|
<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} />
|
<PasswordField value={login.password} onChange={(value) => setLogin({ ...login, 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 /> : 'Sign in'}</button>
|
<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>
|
<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>
|
</form>
|
||||||
@ -95,13 +99,14 @@ export default function AuthScreen({ onAuthenticated }) {
|
|||||||
<h2 className="text-3xl font-black text-slate-900">Join the digital family</h2>
|
<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>
|
<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="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={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 })} />
|
<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"><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 className="col-span-2"><PasswordField value={register.password} onChange={(value) => setRegister({ ...register, password: value })} hidden={hidden} setHidden={setHidden} /></div>
|
||||||
</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>
|
<button className="btn-primary mt-6 w-full" disabled={busy}>{busy ? <Spinner /> : 'Create Metatron.Drive'}</button>
|
||||||
</form>
|
</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>
|
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() {
|
function Spinner() {
|
||||||
return <span className="h-5 w-5 animate-spin rounded-full border-2 border-white/30 border-t-white" />
|
return <span className="h-5 w-5 animate-spin rounded-full border-2 border-white/30 border-t-white" />
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,7 +11,7 @@ import { useUploads } from '../hooks/useUploads.js'
|
|||||||
import Modal from './Modal.jsx'
|
import Modal from './Modal.jsx'
|
||||||
import UploadHistory from './UploadHistory.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 [path, setPath] = useState([])
|
||||||
const [folders, setFolders] = useState([])
|
const [folders, setFolders] = useState([])
|
||||||
const [files, setFiles] = useState([])
|
const [files, setFiles] = useState([])
|
||||||
@ -23,6 +23,8 @@ export default function DriveApp({ user, onLogout }) {
|
|||||||
const [toast, setToast] = useState(null)
|
const [toast, setToast] = useState(null)
|
||||||
const inputRef = useRef(null)
|
const inputRef = useRef(null)
|
||||||
const folderId = path.at(-1)?.id || 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 () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@ -51,7 +53,7 @@ export default function DriveApp({ user, onLogout }) {
|
|||||||
const onUploadComplete = useCallback((destinationId) => {
|
const onUploadComplete = useCallback((destinationId) => {
|
||||||
if ((destinationId || null) === folderId) load()
|
if ((destinationId || null) === folderId) load()
|
||||||
}, [folderId, load])
|
}, [folderId, load])
|
||||||
const uploadState = useUploads(onUploadComplete)
|
const uploadState = useUploads(onUploadComplete, organizationId)
|
||||||
|
|
||||||
async function createFolder(name) {
|
async function createFolder(name) {
|
||||||
try {
|
try {
|
||||||
@ -110,13 +112,13 @@ export default function DriveApp({ user, onLogout }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex h-screen bg-[#f6f8fc]">
|
<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">
|
<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">
|
<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>}
|
{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">
|
<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>
|
<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' ? 'Track every desktop upload' : `Hello, ${user.name.split(' ')[0]}`}</p>
|
<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>
|
</div>
|
||||||
{view === 'drive' && <>
|
{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>
|
<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} />}
|
{uploadState.active.length > 0 && <ActiveUploadBar items={uploadState.active} onOpen={() => setView('history')} onCancel={uploadState.cancel} />}
|
||||||
{toast && <Toast toast={toast} />}
|
{toast && <Toast toast={toast} />}
|
||||||
{modal === 'folder' && <NewFolderModal onClose={() => setModal(null)} onCreate={createFolder} />}
|
{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 === '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)} />}
|
{modal?.type === 'preview' && <PreviewModal item={modal.item} onClose={() => setModal(null)} onDownload={() => downloadFile(modal.item)} />}
|
||||||
</main>
|
</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">
|
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>
|
<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">
|
<nav className="mt-9 space-y-1">
|
||||||
<NavButton active={view === 'drive'} icon={HardDrive} label="My Drive" onClick={() => setView('drive')} />
|
<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')} />
|
<NavButton active={view === 'history'} icon={History} label="Upload history" badge={activeUploads || null} onClick={() => setView('history')} />
|
||||||
</nav>
|
</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">
|
<div className="mt-8 rounded-2xl bg-[#101d42] p-4 text-white">
|
||||||
<ShieldCheck className="text-emerald-300" size={23} />
|
<ShieldCheck className="text-emerald-300" size={23} />
|
||||||
<p className="mt-3 text-sm font-bold">Private workspace</p>
|
<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>
|
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 }) {
|
function ProfileModal({ user, onClose, onLogout, onDeleted, setToast }) {
|
||||||
const [deleting, setDeleting] = useState(false)
|
const [deleting, setDeleting] = useState(false)
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
|
|||||||
@ -12,7 +12,7 @@ function readHistory() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useUploads(onComplete) {
|
export function useUploads(onComplete, organizationId) {
|
||||||
const [uploads, setUploadsState] = useState(readHistory)
|
const [uploads, setUploadsState] = useState(readHistory)
|
||||||
const fileRefs = useRef(new Map())
|
const fileRefs = useRef(new Map())
|
||||||
const abortRefs = useRef(new Map())
|
const abortRefs = useRef(new Map())
|
||||||
@ -63,6 +63,7 @@ export function useUploads(onComplete) {
|
|||||||
size: file.size,
|
size: file.size,
|
||||||
type: file.type || 'application/octet-stream',
|
type: file.type || 'application/octet-stream',
|
||||||
folderId: folderId || null,
|
folderId: folderId || null,
|
||||||
|
organizationId,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
status: 'queued',
|
status: 'queued',
|
||||||
progress: 0,
|
progress: 0,
|
||||||
@ -72,7 +73,7 @@ export function useUploads(onComplete) {
|
|||||||
setUploads((current) => [entry, ...current])
|
setUploads((current) => [entry, ...current])
|
||||||
run(entry, file)
|
run(entry, file)
|
||||||
}
|
}
|
||||||
}, [run, setUploads])
|
}, [organizationId, run, setUploads])
|
||||||
|
|
||||||
const cancel = useCallback((id) => abortRefs.current.get(id)?.abort(), [])
|
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((current) => current.filter((item) => !finalStatuses.has(item.status)))
|
||||||
}, [setUploads])
|
}, [setUploads])
|
||||||
|
|
||||||
const active = useMemo(() => uploads.filter((item) => !finalStatuses.has(item.status)), [uploads])
|
const visibleUploads = useMemo(() => uploads.filter((item) => !item.organizationId || item.organizationId === organizationId), [organizationId, uploads])
|
||||||
return { uploads, active, enqueue, cancel, retry, remove, clearFinished }
|
const active = useMemo(() => visibleUploads.filter((item) => !finalStatuses.has(item.status)), [visibleUploads])
|
||||||
|
return { uploads: visibleUploads, active, enqueue, cancel, retry, remove, clearFinished }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,6 +10,8 @@ const client = axios.create({
|
|||||||
client.interceptors.request.use(async (config) => {
|
client.interceptors.request.use(async (config) => {
|
||||||
const token = await window.desktop.session.read()
|
const token = await window.desktop.session.read()
|
||||||
if (token) config.headers.Authorization = `Bearer ${token}`
|
if (token) config.headers.Authorization = `Bearer ${token}`
|
||||||
|
const organizationId = localStorage.getItem('metatron.drive-organization')
|
||||||
|
if (organizationId) config.headers['X-Organization-Id'] = organizationId
|
||||||
return config
|
return config
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -24,6 +26,12 @@ export const api = {
|
|||||||
async register(input) {
|
async register(input) {
|
||||||
return (await client.post('/auth/register', input)).data
|
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() {
|
async me() {
|
||||||
return (await client.get('/auth/me')).data.user
|
return (await client.get('/auth/me')).data.user
|
||||||
},
|
},
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user