diff --git a/README.md b/README.md index 6d1ac08..688cef7 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/App.jsx b/src/App.jsx index cfb69f1..4feab12 100644 --- a/src/App.jsx +++ b/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 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 - if (status === 'signedOut') return { setUser(profile); setStatus('signedIn') }} /> - return + if (status === 'signedOut') return { applyProfile(profile); setStatus('signedIn') }} /> + return } diff --git a/src/components/AuthScreen.jsx b/src/components/AuthScreen.jsx index dddc4fe..19e8b62 100644 --- a/src/components/AuthScreen.jsx +++ b/src/components/AuthScreen.jsx @@ -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 }) { setLogin({ ...login, username: value })} autoFocus /> setLogin({ ...login, password: value })} hidden={hidden} setHidden={setHidden} /> - {error && } + {error && }{notice && } @@ -95,13 +99,14 @@ export default function AuthScreen({ onAuthenticated }) {

Join the digital family

We create your private Google Drive folder automatically.

-
setRegister({ ...register, name: value })} autoFocus />
+
setRegister({ ...register, organizationCode: value.toUpperCase() })} autoFocus />
+
setRegister({ ...register, name: value })} />
setRegister({ ...register, username: value })} /> setRegister({ ...register, email: value })} />
setRegister({ ...register, phone: value })} />
setRegister({ ...register, password: value })} hidden={hidden} setHidden={setHidden} />
- {error && } + {error && }{notice && } )} @@ -122,6 +127,10 @@ function ErrorBox({ message }) { return
{message}
} +function NoticeBox({ message }) { + return
{message}
+} + function Spinner() { return } diff --git a/src/components/DriveApp.jsx b/src/components/DriveApp.jsx index 04a25dc..b4e03c6 100644 --- a/src/components/DriveApp.jsx +++ b/src/components/DriveApp.jsx @@ -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 (
- setModal('profile')} /> + setModal('join')} onProfile={() => setModal('profile')} />
{view === 'drive' && path.length > 0 && }
-

{view === 'history' ? 'Upload history' : path.at(-1)?.name || 'Metatron.Drive'}

-

{view === 'history' ? 'Track every desktop upload' : `Hello, ${user.name.split(' ')[0]}`}

+

{view === 'history' ? 'Upload history' : path.at(-1)?.name || selectedMembership?.organization.name || 'Metatron.Drive'}

+

{view === 'history' ? `Uploads for ${selectedMembership?.organization.name}` : `Hello, ${user.name.split(' ')[0]} · ${selectedMembership?.organization.code}`}

{view === 'drive' && <> @@ -155,19 +157,23 @@ export default function DriveApp({ user, onLogout }) { {uploadState.active.length > 0 && setView('history')} onCancel={uploadState.cancel} />} {toast && } {modal === 'folder' && setModal(null)} onCreate={createFolder} />} + {modal === 'join' && setModal(null)} onJoined={async (message) => { await onRefreshUser(); setModal(null); setToast({ type: 'success', message }) }} />} {modal === 'profile' && setModal(null)} onLogout={onLogout} onDeleted={onLogout} setToast={setToast} />} {modal?.type === 'preview' && setModal(null)} onDownload={() => downloadFile(modal.item)} />}
) } -function Sidebar({ user, view, setView, activeUploads, onProfile }) { +function Sidebar({ user, memberships, organizationId, onSelectOrganization, view, setView, activeUploads, onJoin, onProfile }) { return