feat: implement workspace management and member invitations

- Added tenant API for managing workspaces, including listing, creating, and inviting members.
- Introduced new models for organizations, workspaces, and workspace memberships in the backend.
- Created frontend components for workspace administration, allowing users to manage workspaces and invite members.
- Updated document listing to support workspace-specific filtering.
- Implemented account settings page for user profile management and password changes.
- Added end-to-end tests for workspace administration and dashboard functionalities.
This commit is contained in:
Anurag Jangid 2026-10-23 01:19:11 +05:30
parent 809235e962
commit bf5cd44dc1
20 changed files with 928 additions and 27 deletions

View file

@ -0,0 +1,19 @@
import { test, expect } from '@playwright/test'
import { login } from './helpers'
test('authenticated user can open workspace administration', async ({ page }) => {
test.skip(!process.env.E2E_USERNAME || !process.env.E2E_PASSWORD, 'Set E2E_USERNAME and E2E_PASSWORD')
expect(await login(page)).toBe(true)
await page.goto('/settings/workspaces')
await expect(page.getByRole('heading', { name: /organizations and workspaces/i })).toBeVisible()
await expect(page.getByText(/your workspaces/i)).toBeVisible()
})
test('dashboard keeps the profile entry while removing redundant header controls', async ({ page }) => {
test.skip(!process.env.E2E_USERNAME || !process.env.E2E_PASSWORD, 'Set E2E_USERNAME and E2E_PASSWORD')
expect(await login(page)).toBe(true)
await page.goto('/dashboard')
await expect(page.getByRole('link', { name: /open profile/i })).toBeVisible()
await expect(page.getByRole('button', { name: 'Help' })).toHaveCount(0)
await expect(page.getByRole('button', { name: 'Customize' })).toHaveCount(0)
})

View file

@ -1,4 +1,5 @@
import { Routes, Route } from 'react-router-dom'
import { Routes, Route, Navigate, useSearchParams, useNavigate } from 'react-router-dom'
import { useEffect, useState } from 'react'
import LandingPage from './pages/LandingPage'
import LoginPage from './pages/LoginPage'
import DashboardPage from './pages/DashboardPage'
@ -9,6 +10,37 @@ import SteadPage from './pages/workspace/SteadPage'
import SteadCreatePage from './pages/workspace/SteadCreatePage'
import ForbiddenPage from './pages/ForbiddenPage'
import ProtectedRoute from './components/ProtectedRoute'
import AccountPage from './pages/AccountPage'
import WorkspaceAdminPage from './pages/WorkspaceAdminPage'
import { authApi, saveAuth } from './lib/api'
function GoogleCallback() {
const [params] = useSearchParams()
const navigate = useNavigate()
const [error, setError] = useState<string | null>(null)
useEffect(() => {
const accessToken = params.get('access_token')
const refreshToken = params.get('refresh_token')
if (!accessToken || !refreshToken) {
setError('Google sign-in did not return valid session credentials.')
return
}
localStorage.setItem('silos_access_token', accessToken)
localStorage.setItem('silos_refresh_token', refreshToken)
void authApi.me().then((user) => {
saveAuth({ access_token: accessToken, refresh_token: refreshToken, token_type: 'bearer', user })
navigate('/dashboard', { replace: true })
}).catch(() => {
localStorage.removeItem('silos_access_token')
localStorage.removeItem('silos_refresh_token')
setError('Google sign-in could not be completed.')
})
}, [navigate, params])
if (error) return <main className="flex min-h-screen items-center justify-center bg-silos-cream p-6"><div className="rounded-2xl bg-white p-6 text-center shadow"><p className="text-sm text-red-700">{error}</p><button type="button" onClick={() => navigate('/login')} className="mt-4 rounded-lg bg-silos-brown px-4 py-2 text-sm text-white">Return to login</button></div></main>
return <Navigate to="/dashboard" replace />
}
export default function App() {
return (
@ -23,6 +55,31 @@ export default function App() {
</ProtectedRoute>
}
/>
<Route
path="/settings"
element={
<ProtectedRoute>
<AccountPage />
</ProtectedRoute>
}
/>
<Route
path="/profile"
element={
<ProtectedRoute>
<AccountPage />
</ProtectedRoute>
}
/>
<Route
path="/settings/workspaces"
element={
<ProtectedRoute>
<WorkspaceAdminPage />
</ProtectedRoute>
}
/>
<Route path="/auth/google/callback" element={<GoogleCallback />} />
<Route
path="/forbidden"
element={

View file

@ -99,6 +99,13 @@ export default function LoginForm({
</div>
<SSOButtons onSelect={onSSOSelect} compact />
<button
type="button"
onClick={() => { window.location.href = `${import.meta.env.VITE_API_URL || '/api/v1'}/auth/google/login` }}
className="w-full rounded-xl border border-silos-cream-dark py-3 text-sm font-semibold text-silos-text transition-colors hover:bg-silos-cream"
>
Continue with Google
</button>
</form>
)
}

View file

@ -4,8 +4,6 @@ import {
Menu,
Search,
Bell,
HelpCircle,
Settings,
ChevronDown,
} from 'lucide-react'
import { logos } from '../../data/brandAssets'
@ -89,24 +87,12 @@ export default function DashboardHeader({
>
<Bell className="h-5 w-5" />
</button>
<button
className="rounded-lg p-2 text-silos-muted transition-colors hover:bg-silos-cream hover:text-silos-text"
aria-label="Help"
>
<HelpCircle className="h-5 w-5" />
</button>
<button
className="rounded-lg p-2 text-silos-muted transition-colors hover:bg-silos-cream hover:text-silos-text"
aria-label="Settings"
>
<Settings className="h-5 w-5" />
</button>
<button className="ml-1 flex items-center gap-1.5 rounded-full py-1 pl-1 pr-2 transition-colors hover:bg-silos-cream">
<Link to="/profile" className="ml-1 flex items-center gap-1.5 rounded-full py-1 pl-1 pr-2 transition-colors hover:bg-silos-cream" aria-label="Open profile">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-silos-tan text-sm font-semibold text-white">
{displayName[0]}
</div>
<ChevronDown className="h-4 w-4 text-silos-muted" />
</button>
</Link>
</div>
</header>
<SearchOverlay open={searchOpen} onClose={() => setSearchOpen(false)} initialQuery={draft} />

View file

@ -1,4 +1,4 @@
import { useState } from 'react'
import { FormEvent, useState } from 'react'
import { Link } from 'react-router-dom'
import {
Home,
@ -9,12 +9,12 @@ import {
ChevronDown,
ChevronRight,
Plus,
Palette,
UserPlus,
type LucideIcon,
} from 'lucide-react'
import { navItems, type NavItemId } from '../../data/dashboardData'
import type { DocumentRecord } from '../../lib/documents'
import { tenantApi } from '../../lib/api'
const iconMap: Record<string, LucideIcon> = {
Home,
@ -42,6 +42,26 @@ export default function DashboardSidebar({
spaces = [],
}: DashboardSidebarProps) {
const [personalOpen, setPersonalOpen] = useState(true)
const [inviteOpen, setInviteOpen] = useState(false)
const [inviteEmail, setInviteEmail] = useState('')
const [inviteMessage, setInviteMessage] = useState<string | null>(null)
async function sendInvite(event: FormEvent) {
event.preventDefault()
try {
const workspaces = await tenantApi.listWorkspaces()
if (!workspaces[0]) {
setInviteMessage('Create a workspace before inviting people.')
return
}
const invitation = await tenantApi.invite(workspaces[0].id, inviteEmail, 'member')
window.location.href = `mailto:${encodeURIComponent(inviteEmail)}?subject=${encodeURIComponent(`Join ${workspaces[0].name} on Silos`)}&body=${encodeURIComponent(`You are invited to join ${workspaces[0].name} on Silos. Open this link to accept: ${invitation.invite_url ?? ''}`)}`
setInviteEmail('')
setInviteMessage('Your email app is ready with the invitation link.')
} catch (error) {
setInviteMessage(error instanceof Error ? error.message : 'Could not create an invitation.')
}
}
if (collapsed) {
return (
@ -144,15 +164,22 @@ export default function DashboardSidebar({
</nav>
<div className="space-y-2 border-t border-silos-cream-dark p-3">
<button className="flex w-full items-center justify-center gap-2 rounded-xl border border-silos-cream-dark bg-silos-cream/40 py-2.5 text-sm font-medium text-silos-text transition-colors hover:bg-silos-cream">
<Palette className="h-4 w-4" />
Customize
</button>
<button className="flex w-full items-center justify-center gap-2 rounded-xl border border-silos-cream-dark bg-silos-cream/40 py-2.5 text-sm font-medium text-silos-text transition-colors hover:bg-silos-cream">
<button onClick={() => setInviteOpen(true)} className="flex w-full items-center justify-center gap-2 rounded-xl border border-silos-cream-dark bg-silos-cream/40 py-2.5 text-sm font-medium text-silos-text transition-colors hover:bg-silos-cream">
<UserPlus className="h-4 w-4" />
Invite Fellow
</button>
</div>
{inviteOpen && (
<div className="absolute bottom-20 left-3 right-3 z-40 rounded-2xl border border-silos-cream-dark bg-white p-4 shadow-xl">
<div className="flex items-center justify-between"><h2 className="text-sm font-semibold">Invite a fellow</h2><button type="button" onClick={() => setInviteOpen(false)} className="text-xs text-silos-muted">Close</button></div>
<p className="mt-2 text-xs text-silos-muted">Create an invitation link and open it in your email app.</p>
<form onSubmit={(event) => void sendInvite(event)} className="mt-3 space-y-2">
<input type="email" value={inviteEmail} onChange={(event) => setInviteEmail(event.target.value)} placeholder="person@example.com" required className="w-full rounded-lg border border-silos-cream-dark px-3 py-2 text-sm" />
<button type="submit" className="w-full rounded-lg bg-silos-brown px-3 py-2 text-sm font-medium text-white">Prepare email invite</button>
</form>
{inviteMessage && <p className="mt-2 text-xs text-silos-muted">{inviteMessage}</p>}
</div>
)}
</aside>
)
}

View file

@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { listDocuments, type DocumentRecord } from '../lib/documents'
import { sortByRecent } from '../lib/documentUtils'
import { tenantApi } from '../lib/api'
export function useDashboardDocuments() {
const [documents, setDocuments] = useState<DocumentRecord[]>([])
@ -11,7 +12,12 @@ export function useDashboardDocuments() {
setLoading(true)
setError(null)
try {
const docs = await listDocuments(undefined, { sort: 'last_visited_at' })
const workspaces = await tenantApi.listWorkspaces()
const selected = Number(localStorage.getItem('silos_workspace_id')) || workspaces[0]?.id
if (selected) {
localStorage.setItem('silos_workspace_id', String(selected))
}
const docs = await listDocuments(undefined, { sort: 'last_visited_at', workspaceId: selected })
setDocuments(docs)
} catch {
setError('Could not load documents. Is MongoDB running?')

View file

@ -13,6 +13,38 @@ export interface User {
updated_at: string
}
export type TenantRole = 'owner' | 'admin' | 'member' | 'viewer'
export interface ApiWorkspace {
id: number
organization_id: number
organization_name: string
name: string
slug: string
role: TenantRole
created_at: string
}
export interface ApiWorkspaceMember {
user_id: number
email: string
full_name: string
username: string
role: TenantRole
joined_at: string
}
export interface ApiInvitation {
id: number
workspace_id: number
email: string
role: TenantRole
expires_at: string
accepted: boolean
token?: string
invite_url?: string
}
export interface AuthTokens {
access_token: string
refresh_token: string
@ -155,6 +187,29 @@ export const authApi = {
googleLoginUrl: () => `${API_BASE}/auth/google/login`,
}
export const tenantApi = {
listWorkspaces: () => request<ApiWorkspace[]>('/tenants/workspaces'),
createWorkspace: (organization_name: string, workspace_name: string) =>
request<ApiWorkspace>('/tenants/workspaces', {
method: 'POST',
body: JSON.stringify({ organization_name, workspace_name }),
}),
listMembers: (workspaceId: number) =>
request<ApiWorkspaceMember[]>(`/tenants/workspaces/${workspaceId}/members`),
invite: (workspaceId: number, email: string, role: Exclude<TenantRole, 'owner'>) =>
request<ApiInvitation>(`/tenants/workspaces/${workspaceId}/invitations`, {
method: 'POST',
body: JSON.stringify({ email, role }),
}),
updateRole: (workspaceId: number, userId: number, role: Exclude<TenantRole, 'owner'>) =>
request<ApiWorkspaceMember>(`/tenants/workspaces/${workspaceId}/members/${userId}`, {
method: 'PATCH',
body: JSON.stringify({ role }),
}),
acceptInvitation: (token: string) =>
request<ApiWorkspaceMember>(`/tenants/invitations/${encodeURIComponent(token)}/accept`, { method: 'POST' }),
}
export function saveAuth(tokens: AuthTokens) {
localStorage.setItem('silos_access_token', tokens.access_token)
localStorage.setItem('silos_refresh_token', tokens.refresh_token)
@ -247,6 +302,7 @@ export interface ApiDocument {
owner_id?: number | null
visibility?: 'private' | 'members'
members?: DocumentMember[]
workspace_id?: number | null
}
export interface ApiDocumentVersion {
@ -266,6 +322,7 @@ export interface DocumentCreatePayload {
template_id?: string
stead_id?: string
parent_page_id?: string | null
workspace_id?: number | null
}
export interface DocumentUpdatePayload {
@ -274,6 +331,7 @@ export interface DocumentUpdatePayload {
starred?: boolean
stead_id?: string
parent_page_id?: string
workspace_id?: number
}
export const documentsApi = {
@ -283,6 +341,7 @@ export const documentsApi = {
parent_page_id?: string
starred?: boolean
sort?: 'updated_at' | 'last_visited_at'
workspace_id?: number
}) => {
const search = new URLSearchParams()
if (params?.type) search.set('type', params.type)
@ -290,6 +349,7 @@ export const documentsApi = {
if (params?.parent_page_id) search.set('parent_page_id', params.parent_page_id)
if (params?.starred !== undefined) search.set('starred', String(params.starred))
if (params?.sort) search.set('sort', params.sort)
if (params?.workspace_id) search.set('workspace_id', String(params.workspace_id))
const qs = search.toString()
return request<ApiDocument[]>(`/documents${qs ? `?${qs}` : ''}`)
},

View file

@ -8,6 +8,7 @@ export interface DocumentMeta {
templateId?: ShedTemplateId
steadId?: string
parentPageId?: string
workspaceId?: number
createdAt: string
updatedAt: string
createdBy: string
@ -26,6 +27,7 @@ export interface ListDocumentsOptions {
sort?: 'updated_at' | 'last_visited_at'
starred?: boolean
parentPageId?: string
workspaceId?: number
}
function fromApi(doc: ApiDocument): DocumentRecord {
@ -45,6 +47,7 @@ function fromApi(doc: ApiDocument): DocumentRecord {
ownerId: doc.owner_id ?? undefined,
visibility: doc.visibility,
members: doc.members ?? [],
workspaceId: doc.workspace_id ?? undefined,
}
}
@ -57,6 +60,7 @@ export async function listDocuments(
starred: options?.starred,
sort: options?.sort,
parent_page_id: options?.parentPageId,
workspace_id: options?.workspaceId,
})
return docs.filter((doc) => !(doc.content?.archived === true)).map(fromApi)
}

View file

@ -0,0 +1,109 @@
import { FormEvent, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { ArrowLeft, Check, LockKeyhole, UserRound } from 'lucide-react'
import { authApi, clearAuth, getStoredUser, saveAuth, type User } from '../lib/api'
export default function AccountPage() {
const navigate = useNavigate()
const stored = getStoredUser()
const [user, setUser] = useState<User | null>(stored)
const [fullName, setFullName] = useState(stored?.full_name ?? '')
const [occupation, setOccupation] = useState(stored?.occupation ?? '')
const [phone, setPhone] = useState(stored?.phone ?? '')
const [currentPassword, setCurrentPassword] = useState('')
const [newPassword, setNewPassword] = useState('')
const [message, setMessage] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
async function saveProfile(event: FormEvent) {
event.preventDefault()
setMessage(null)
setError(null)
try {
const updated = await authApi.updateMe({ full_name: fullName, occupation, phone })
setUser(updated)
saveAuth({
access_token: localStorage.getItem('silos_access_token') ?? '',
refresh_token: localStorage.getItem('silos_refresh_token') ?? '',
token_type: 'bearer',
user: updated,
})
setMessage('Profile updated.')
} catch (err) {
setError(err instanceof Error ? err.message : 'Could not update profile.')
}
}
async function changePassword(event: FormEvent) {
event.preventDefault()
setMessage(null)
setError(null)
try {
const result = await authApi.changePassword(currentPassword, newPassword)
setCurrentPassword('')
setNewPassword('')
setMessage(result.message)
} catch (err) {
setError(err instanceof Error ? err.message : 'Could not change password.')
}
}
function signOut() {
clearAuth()
navigate('/login')
}
if (!user) return null
return (
<main className="min-h-screen bg-silos-cream px-4 py-6 text-silos-text sm:px-8">
<div className="mx-auto max-w-4xl">
<Link to="/dashboard" className="mb-6 inline-flex items-center gap-2 text-sm text-silos-muted hover:text-silos-brown">
<ArrowLeft className="h-4 w-4" /> Back to dashboard
</Link>
<div className="mb-6">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-silos-tan">Account</p>
<h1 className="mt-1 text-3xl font-semibold">Profile and settings</h1>
<p className="mt-2 text-sm text-silos-muted">Manage your identity, personal details, and account security.</p>
</div>
{(message || error) && <div className={`mb-5 flex items-center gap-2 rounded-xl border px-4 py-3 text-sm ${error ? 'border-red-200 bg-red-50 text-red-800' : 'border-green-200 bg-green-50 text-green-800'}`}>{!error && <Check className="h-4 w-4" />}{error ?? message}</div>}
<div className="grid gap-5 lg:grid-cols-[220px_1fr]">
<aside className="h-fit rounded-2xl border border-silos-cream-dark bg-white p-2 shadow-sm">
<div className="flex items-center gap-3 rounded-xl bg-silos-cream px-3 py-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-silos-brown font-semibold text-white">{user.full_name[0]?.toUpperCase() ?? 'U'}</div>
<div className="min-w-0"><p className="truncate text-sm font-semibold">{user.full_name}</p><p className="truncate text-xs text-silos-muted">{user.email}</p></div>
</div>
<p className="px-3 pb-1 pt-4 text-[10px] font-semibold uppercase tracking-wider text-silos-muted">Settings</p>
<a href="#profile" className="flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-silos-brown hover:bg-silos-cream"><UserRound className="h-4 w-4" /> Profile</a>
<a href="#security" className="flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-silos-muted hover:bg-silos-cream"><LockKeyhole className="h-4 w-4" /> Security</a>
<Link to="/settings/workspaces" className="mt-1 block rounded-lg px-3 py-2 text-sm text-silos-muted hover:bg-silos-cream">Workspaces & members</Link>
<button type="button" onClick={signOut} className="mt-4 w-full rounded-lg px-3 py-2 text-left text-sm text-red-700 hover:bg-red-50">Sign out</button>
</aside>
<div className="space-y-5">
<form id="profile" onSubmit={(event) => void saveProfile(event)} className="rounded-2xl border border-silos-cream-dark bg-white p-5 shadow-sm">
<h2 className="text-lg font-semibold">Profile</h2>
<p className="mt-1 text-sm text-silos-muted">This information is shown to people you collaborate with.</p>
<div className="mt-5 grid gap-4 sm:grid-cols-2">
<label className="text-sm font-medium">Full name<input value={fullName} onChange={(event) => setFullName(event.target.value)} className="mt-1.5 w-full rounded-xl border border-silos-cream-dark px-3 py-2.5 font-normal outline-none focus:border-silos-brown" required /></label>
<label className="text-sm font-medium">Username<input value={user.username} readOnly className="mt-1.5 w-full rounded-xl border border-silos-cream-dark bg-silos-cream px-3 py-2.5 font-normal text-silos-muted" /></label>
<label className="text-sm font-medium">Email<input value={user.email} readOnly className="mt-1.5 w-full rounded-xl border border-silos-cream-dark bg-silos-cream px-3 py-2.5 font-normal text-silos-muted" /></label>
<label className="text-sm font-medium">Occupation<input value={occupation} onChange={(event) => setOccupation(event.target.value)} className="mt-1.5 w-full rounded-xl border border-silos-cream-dark px-3 py-2.5 font-normal outline-none focus:border-silos-brown" /></label>
<label className="text-sm font-medium sm:col-span-2">Phone<input value={phone} onChange={(event) => setPhone(event.target.value)} className="mt-1.5 w-full rounded-xl border border-silos-cream-dark px-3 py-2.5 font-normal outline-none focus:border-silos-brown" /></label>
</div>
<button type="submit" className="mt-5 rounded-xl bg-silos-brown px-4 py-2.5 text-sm font-semibold text-white hover:bg-silos-brown-dark">Save profile</button>
</form>
<form id="security" onSubmit={(event) => void changePassword(event)} className="rounded-2xl border border-silos-cream-dark bg-white p-5 shadow-sm">
<h2 className="text-lg font-semibold">Security</h2>
<p className="mt-1 text-sm text-silos-muted">Change your password. Google-authenticated users can continue using Google sign-in.</p>
<div className="mt-5 grid gap-4 sm:grid-cols-2">
<label className="text-sm font-medium">Current password<input type="password" value={currentPassword} onChange={(event) => setCurrentPassword(event.target.value)} className="mt-1.5 w-full rounded-xl border border-silos-cream-dark px-3 py-2.5 font-normal outline-none focus:border-silos-brown" required /></label>
<label className="text-sm font-medium">New password<input type="password" value={newPassword} onChange={(event) => setNewPassword(event.target.value)} minLength={8} className="mt-1.5 w-full rounded-xl border border-silos-cream-dark px-3 py-2.5 font-normal outline-none focus:border-silos-brown" required /></label>
</div>
<button type="submit" className="mt-5 rounded-xl border border-silos-cream-dark px-4 py-2.5 text-sm font-semibold text-silos-text hover:bg-silos-cream">Change password</button>
</form>
</div>
</div>
</div>
</main>
)
}

View file

@ -0,0 +1,90 @@
import { FormEvent, useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { ArrowLeft, Building2, MailPlus, ShieldCheck, Users } from 'lucide-react'
import { tenantApi, type ApiWorkspace, type ApiWorkspaceMember, type TenantRole } from '../lib/api'
export default function WorkspaceAdminPage() {
const [workspaces, setWorkspaces] = useState<ApiWorkspace[]>([])
const [selected, setSelected] = useState<ApiWorkspace | null>(null)
const [members, setMembers] = useState<ApiWorkspaceMember[]>([])
const [organizationName, setOrganizationName] = useState('')
const [workspaceName, setWorkspaceName] = useState('')
const [email, setEmail] = useState('')
const [role, setRole] = useState<Exclude<TenantRole, 'owner'>>('member')
const [message, setMessage] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
void tenantApi.listWorkspaces().then((items) => {
setWorkspaces(items)
if (items[0]) setSelected(items[0])
}).catch((err: unknown) => setError(err instanceof Error ? err.message : 'Could not load workspaces.'))
}, [])
useEffect(() => {
if (!selected) {
setMembers([])
return
}
void tenantApi.listMembers(selected.id).then(setMembers).catch((err: unknown) => setError(err instanceof Error ? err.message : 'Could not load members.'))
}, [selected])
async function createWorkspace(event: FormEvent) {
event.preventDefault()
try {
const workspace = await tenantApi.createWorkspace(organizationName, workspaceName)
setWorkspaces((items) => [...items, workspace])
setSelected(workspace)
setOrganizationName('')
setWorkspaceName('')
setMessage('Workspace created.')
} catch (err) {
setError(err instanceof Error ? err.message : 'Could not create workspace.')
}
}
async function invite(event: FormEvent) {
event.preventDefault()
if (!selected) return
try {
await tenantApi.invite(selected.id, email, role)
setEmail('')
setMessage('Invitation created. Connect your email provider to deliver it automatically.')
} catch (err) {
setError(err instanceof Error ? err.message : 'Could not invite member.')
}
}
async function changeRole(userId: number, nextRole: Exclude<TenantRole, 'owner'>) {
if (!selected) return
try {
const member = await tenantApi.updateRole(selected.id, userId, nextRole)
setMembers((items) => items.map((item) => item.user_id === member.user_id ? member : item))
setMessage('Member role updated.')
} catch (err) {
setError(err instanceof Error ? err.message : 'Could not update role.')
}
}
return (
<main className="min-h-screen bg-silos-cream px-4 py-6 text-silos-text sm:px-8">
<div className="mx-auto max-w-6xl">
<Link to="/settings" className="mb-6 inline-flex items-center gap-2 text-sm text-silos-muted hover:text-silos-brown"><ArrowLeft className="h-4 w-4" /> Back to settings</Link>
<div className="mb-6"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-silos-tan">Administration</p><h1 className="mt-1 text-3xl font-semibold">Organizations and workspaces</h1><p className="mt-2 text-sm text-silos-muted">Create collaboration boundaries, invite people, and manage workspace roles.</p></div>
{(message || error) && <p className={`mb-5 rounded-xl border px-4 py-3 text-sm ${error ? 'border-red-200 bg-red-50 text-red-800' : 'border-green-200 bg-green-50 text-green-800'}`}>{error ?? message}</p>}
<div className="grid gap-5 lg:grid-cols-[280px_1fr]">
<section className="rounded-2xl border border-silos-cream-dark bg-white p-4 shadow-sm">
<div className="flex items-center justify-between"><h2 className="font-semibold">Your workspaces</h2><Building2 className="h-4 w-4 text-silos-tan" /></div>
<div className="mt-3 space-y-2">{workspaces.map((workspace) => <button type="button" key={workspace.id} onClick={() => setSelected(workspace)} className={`w-full rounded-xl border px-3 py-3 text-left ${selected?.id === workspace.id ? 'border-silos-brown bg-silos-cream' : 'border-silos-cream-dark hover:bg-silos-cream/50'}`}><p className="text-sm font-medium">{workspace.name}</p><p className="mt-1 text-xs text-silos-muted">{workspace.organization_name} · {workspace.role}</p></button>)}{workspaces.length === 0 && <p className="text-sm text-silos-muted">No workspaces yet.</p>}</div>
<form onSubmit={(event) => void createWorkspace(event)} className="mt-5 border-t border-silos-cream-dark pt-4"><p className="text-xs font-semibold uppercase tracking-wider text-silos-muted">New workspace</p><input value={organizationName} onChange={(event) => setOrganizationName(event.target.value)} placeholder="Organization name" className="mt-2 w-full rounded-lg border border-silos-cream-dark px-3 py-2 text-sm" required /><input value={workspaceName} onChange={(event) => setWorkspaceName(event.target.value)} placeholder="Workspace name" className="mt-2 w-full rounded-lg border border-silos-cream-dark px-3 py-2 text-sm" required /><button type="submit" className="mt-2 w-full rounded-lg bg-silos-brown px-3 py-2 text-sm font-medium text-white">Create workspace</button></form>
</section>
{selected ? <section className="space-y-5">
<div className="rounded-2xl border border-silos-cream-dark bg-white p-5 shadow-sm"><div className="flex items-start justify-between"><div><h2 className="text-xl font-semibold">{selected.name}</h2><p className="mt-1 text-sm text-silos-muted">{selected.organization_name} · Your role: {selected.role}</p></div><ShieldCheck className="h-5 w-5 text-silos-tan" /></div></div>
<div className="rounded-2xl border border-silos-cream-dark bg-white p-5 shadow-sm"><div className="flex items-center gap-2"><Users className="h-4 w-4 text-silos-brown" /><h2 className="font-semibold">Members</h2></div><div className="mt-4 divide-y divide-silos-cream-dark">{members.map((member) => <div key={member.user_id} className="flex items-center justify-between gap-3 py-3"><div><p className="text-sm font-medium">{member.full_name}</p><p className="text-xs text-silos-muted">{member.email} · @{member.username}</p></div>{member.role === 'owner' ? <span className="text-xs font-medium text-silos-brown">Owner</span> : <select value={member.role} onChange={(event) => void changeRole(member.user_id, event.target.value as Exclude<TenantRole, 'owner'>)} disabled={!['owner', 'admin'].includes(selected.role)} className="rounded-lg border border-silos-cream-dark px-2 py-1 text-xs"><option value="admin">Admin</option><option value="member">Member</option><option value="viewer">Viewer</option></select>}</div>)}</div></div>
{['owner', 'admin'].includes(selected.role) && <form onSubmit={(event) => void invite(event)} className="rounded-2xl border border-silos-cream-dark bg-white p-5 shadow-sm"><div className="flex items-center gap-2"><MailPlus className="h-4 w-4 text-silos-brown" /><h2 className="font-semibold">Invite a member</h2></div><div className="mt-4 flex flex-col gap-2 sm:flex-row"><input type="email" value={email} onChange={(event) => setEmail(event.target.value)} placeholder="person@example.com" className="flex-1 rounded-lg border border-silos-cream-dark px-3 py-2 text-sm" required /><select value={role} onChange={(event) => setRole(event.target.value as Exclude<TenantRole, 'owner'>)} className="rounded-lg border border-silos-cream-dark px-3 py-2 text-sm"><option value="member">Member</option><option value="admin">Admin</option><option value="viewer">Viewer</option></select><button type="submit" className="rounded-lg bg-silos-brown px-4 py-2 text-sm font-medium text-white">Invite</button></div></form>}
</section> : <section className="rounded-2xl border border-dashed border-silos-cream-dark bg-white p-10 text-center text-sm text-silos-muted">Create or select a workspace to manage members.</section>}
</div>
</div>
</main>
)
}

View file

@ -0,0 +1,4 @@
{
"status": "passed",
"failedTests": []
}