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:
parent
809235e962
commit
bf5cd44dc1
20 changed files with 928 additions and 27 deletions
19
frontend/e2e/phase10-tenant.spec.ts
Normal file
19
frontend/e2e/phase10-tenant.spec.ts
Normal 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)
|
||||
})
|
||||
|
|
@ -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={
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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?')
|
||||
|
|
|
|||
|
|
@ -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}` : ''}`)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
109
frontend/src/pages/AccountPage.tsx
Normal file
109
frontend/src/pages/AccountPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
90
frontend/src/pages/WorkspaceAdminPage.tsx
Normal file
90
frontend/src/pages/WorkspaceAdminPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
4
frontend/test-results/.last-run.json
Normal file
4
frontend/test-results/.last-run.json
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"status": "passed",
|
||||
"failedTests": []
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue