# MarrowStack Team & Workspaces Block
A production-ready multi-tenant workspace management system for Next.js 14 SaaS applications. Includes workspace CRUD, role-based access control (RBAC), member management, invitations, and a sleek dashboard UI.
**Stack:** Next.js 14 · Supabase · Resend (optional, for email invites)
---
## 📋 Features
### Core Workspace Management
- ✅ **Create workspaces** with auto-generated slugs and unique names
- ✅ **Update workspace** settings (name, logo, custom settings JSON)
- ✅ **Delete workspaces** with safety checks (owner-only)
- ✅ **Retrieve workspaces** by ID or slug
- ✅ **List user workspaces** with role information
### Team & Membership
- ✅ **Member CRUD:** Add, remove, list workspace members
- ✅ **Role management:** Assign/update roles (owner → admin → member → viewer)
- ✅ **Ownership transfer:** Securely transfer workspace ownership
- ✅ **Member view:** Join profiles with email, name, and avatar data
### Invitations
- ✅ **Send invites** to any email with configurable roles
- ✅ **Unique tokens** for invite links (auto-generated UUIDs)
- ✅ **7-day expiry** with customizable duration
- ✅ **Accept invites** with automatic member creation
- ✅ **Revoke pending** invites
- ✅ **List pending** invites per workspace
- ✅ **Re-invite handling:** Sending to same email generates new token
### Security & Permissions
- ✅ **Row-Level Security (RLS)** on all tables
- ✅ **Role-based permissions** with granular action checks
- ✅ **Owner-only operations:** Billing, deletion, ownership transfer
- ✅ **Admin operations:** Member management, invitations, settings
- ✅ **Viewer-only access:** Read-only workspace viewing
### UI Dashboard
- ✅ **Sleek dark theme** with Tailwind CSS
- ✅ **Member list** with avatars, roles, status
- ✅ **Invite form** with role selection
- ✅ **Pending invites** section with revoke controls
- ✅ **Quick stats** panel with workspace info
- ✅ **Admin controls** for billing, deletion, data export
- ✅ **Responsive design** (mobile-friendly)
---
## 🗄️ Database Schema
### `workspaces`
Primary workspace records with ownership and billing info.
```sql
CREATE TABLE workspaces (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
slug TEXT UNIQUE NOT NULL,
owner_id UUID NOT NULL REFERENCES profiles(id) ON DELETE RESTRICT,
plan TEXT NOT NULL DEFAULT 'free' CHECK (plan IN ('free','pro','enterprise')),
logo_url TEXT,
settings JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
```
**RLS Policies:**
- `ws_member_select`: Members can view workspaces they're part of
- `ws_owner_all`: Owners have full CRUD access
---
### `workspace_members`
Maps users to workspaces with roles.
```sql
CREATE TABLE workspace_members (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
role TEXT NOT NULL DEFAULT 'member'
CHECK (role IN ('owner','admin','member','viewer')),
joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (workspace_id, user_id)
);
```
**RLS Policies:**
- `wm_member_select`: Members see workspace members
- `wm_admin_manage`: Admins/owners manage members
---
### `workspace_invites`
Pending invitations with unique tokens and expiry.
```sql
CREATE TABLE workspace_invites (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
email TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'member' CHECK (role IN ('admin','member','viewer')),
token TEXT UNIQUE NOT NULL DEFAULT gen_random_uuid()::text,
invited_by UUID REFERENCES profiles(id),
accepted_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ NOT NULL DEFAULT (NOW() + INTERVAL '7 days'),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (workspace_id, email)
);
```
**RLS Policies:**
- `wi_member_select`: Only admins/owners see invites
---
### `workspace_members_view` (Helper View)
Joins members with profile data for convenient querying.
```sql
CREATE OR REPLACE VIEW workspace_members_view AS
SELECT
wm.id, wm.workspace_id, wm.role, wm.joined_at,
p.id AS user_id, p.email, p.name, p.avatar_url
FROM workspace_members wm
JOIN profiles p ON p.id = wm.user_id;
```
---
## 🎯 Role Hierarchy & Permissions
### Role Levels
```
viewer (0) < member (1) < admin (2) < owner (3)
```
### Permission Matrix
| Action | Viewer | Member | Admin | Owner |
|--------|--------|--------|-------|-------|
| `workspace:view` | ✅ | ✅ | ✅ | ✅ |
| `member:view` | ✅ | ✅ | ✅ | ✅ |
| `workspace:invite` | ❌ | ❌ | ✅ | ✅ |
| `member:remove` | ❌ | ❌ | ✅ | ✅ |
| `member:change_role` | ❌ | ❌ | ✅ | ✅ |
| `workspace:settings` | ❌ | ❌ | ✅ | ✅ |
| `workspace:billing` | ❌ | ❌ | ❌ | ✅ |
| `workspace:delete` | ❌ | ❌ | ❌ | ✅ |
---
## 🚀 Quick Start
### 1. Install Dependencies
```bash
npm install @supabase/supabase-js resend
```
### 2. Set Up Environment Variables
```env
# .env.local
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
RESEND_API_KEY=your-resend-key-optional
```
### 3. Run SQL Migrations
Copy the SQL schema from the comments in `lib/workspaces.ts` and execute in your Supabase SQL editor. This creates all tables, RLS policies, and views.
### 4. Import & Use
#### Server-side (API routes, server components)
```tsx
import {
createWorkspace,
inviteMember,
getWorkspaceMembers,
updateMemberRole
} from '@/lib/workspaces'
// Create workspace
const ws = await createWorkspace(userId, 'My Team', 'free')
// Invite member
const invite = await inviteMember(ws.id, userId, 'john@company.com', 'admin')
// Get members
const members = await getWorkspaceMembers(ws.id)
```
#### Client-side (React components)
```tsx
import { useWorkspace } from '@/lib/workspaces'
export default function Dashboard({ wsId }) {
const { workspace, members, userRole, canDo, refresh } = useWorkspace(wsId)
if (canDo('workspace:invite')) {
// Show invite form
}
return (
// Your component
)
}
```
---
## 📚 API Reference
### Workspace Operations
#### `createWorkspace(ownerId, name, plan?)`
Creates a new workspace with auto-generated slug.
```typescript
const ws = await createWorkspace(
'user-123',
'Engineering Team',
'pro'
)
// Returns: Workspace
```
**Parameters:**
- `ownerId` (string, required): User ID of workspace owner
- `name` (string, required): Workspace name
- `plan` (WorkspacePlan, optional): 'free' | 'pro' | 'enterprise', default: 'free'
**Returns:** `Workspace` object
---
#### `getWorkspace(idOrSlug)`
Fetch a single workspace by ID or slug.
```typescript
const ws = await getWorkspace('my-team-slug')
```
**Parameters:**
- `idOrSlug` (string): Workspace UUID or slug
**Returns:** `Workspace | null`
---
#### `getUserWorkspaces(userId)`
Get all workspaces a user belongs to.
```typescript
const workspaces = await getUserWorkspaces('user-123')
// Returns: Workspace[] with userRole attached
```
**Parameters:**
- `userId` (string): User UUID
**Returns:** `Workspace[]` (with `userRole` field)
---
#### `updateWorkspace(wsId, updates)`
Update workspace properties.
```typescript
await updateWorkspace('ws-123', {
name: 'New Name',
logo_url: 'https://...',
settings: { theme: 'dark' }
})
```
**Parameters:**
- `wsId` (string): Workspace UUID
- `updates` (object):
- `name?` (string): New workspace name
- `logo_url?` (string | null): Logo image URL
- `settings?` (Record<string, any>): Custom settings JSON
**Returns:** Promise (void)
---
#### `deleteWorkspace(wsId, ownerId)`
Delete a workspace (owner-only).
```typescript
await deleteWorkspace('ws-123', 'user-123')
```
**Parameters:**
- `wsId` (string): Workspace UUID
- `ownerId` (string): User UUID (must be workspace owner)
**Returns:** Promise (void)
---
### Member Management
#### `getWorkspaceMembers(wsId)`
List all members in a workspace with profile data.
```typescript
const members = await getWorkspaceMembers('ws-123')
// Returns: WorkspaceMember[] with email, name, avatar
```
**Parameters:**
- `wsId` (string): Workspace UUID
**Returns:** `WorkspaceMember[]`
---
#### `getMemberRole(wsId, userId)`
Get a member's role in a workspace.
```typescript
const role = await getMemberRole('ws-123', 'user-456')
// Returns: 'owner' | 'admin' | 'member' | 'viewer' | null
```
**Parameters:**
- `wsId` (string): Workspace UUID
- `userId` (string): User UUID
**Returns:** `WorkspaceRole | null`
---
#### `updateMemberRole(wsId, userId, newRole)`
Change a member's role (cannot assign 'owner').
```typescript
await updateMemberRole('ws-123', 'user-456', 'admin')
```
**Parameters:**
- `wsId` (string): Workspace UUID
- `userId` (string): User UUID
- `newRole` (WorkspaceRole): 'admin' | 'member' | 'viewer'
**Returns:** Promise (void)
**Throws:** Error if trying to assign 'owner' role
---
#### `removeMember(wsId, userId)`
Remove a member from a workspace.
```typescript
await removeMember('ws-123', 'user-456')
```
**Parameters:**
- `wsId` (string): Workspace UUID
- `userId` (string): User UUID
**Returns:** Promise (void)
---
#### `transferOwnership(wsId, currentOwnerId, newOwnerId)`
Transfer workspace ownership (current owner becomes admin).
```typescript
await transferOwnership('ws-123', 'old-owner', 'new-owner')
```
**Parameters:**
- `wsId` (string): Workspace UUID
- `currentOwnerId` (string): Current owner user ID
- `newOwnerId` (string): New owner user ID
**Returns:** Promise (void)
---
### Invitations
#### `inviteMember(wsId, inviterId, email, role?)`
Send an invitation to a new or existing user.
```typescript
const invite = await inviteMember(
'ws-123',
'user-456',
'john@company.com',
'member'
)
```
**Parameters:**
- `wsId` (string): Workspace UUID
- `inviterId` (string): User UUID of inviter
- `email` (string): Email to invite (lowercased)
- `role` (WorkspaceRole, optional): 'admin' | 'member' | 'viewer', default: 'member'
**Returns:** `WorkspaceInvite` (includes token)
**Throws:** Error if email is already a member
---
#### `getInviteByToken(token)`
Validate and retrieve an invite by token (must be unexpired and unaccepted).
```typescript
const invite = await getInviteByToken('token-123')
```
**Parameters:**
- `token` (string): Invite token from email link
**Returns:** `WorkspaceInvite | null`
---
#### `acceptInvite(token, userId)`
Accept an invitation and add user to workspace.
```typescript
await acceptInvite('token-123', 'user-456')
```
**Parameters:**
- `token` (string): Invite token
- `userId` (string): User UUID accepting the invite
**Returns:** Promise (void)
**Throws:** Error if invite is invalid or expired
---
#### `revokeInvite(inviteId)`
Delete a pending invitation.
```typescript
await revokeInvite('invite-123')
```
**Parameters:**
- `inviteId` (string): Invite UUID
**Returns:** Promise (void)
---
#### `getPendingInvites(wsId)`
List all unexpired, unaccepted invites for a workspace.
```typescript
const invites = await getPendingInvites('ws-123')
```
**Parameters:**
- `wsId` (string): Workspace UUID
**Returns:** `WorkspaceInvite[]`
---
### Permission Checking
#### `hasWorkspacePermission(userRole, required)`
Check if a user role meets a minimum permission level.
```typescript
const canManage = hasWorkspacePermission('admin', 'member')
// true — admin >= member in hierarchy
```
**Parameters:**
- `userRole` (WorkspaceRole): User's current role
- `required` (WorkspaceRole): Required minimum role
**Returns:** boolean
---
#### `canDo(userRole, action)`
Check if a user can perform a specific action.
```typescript
const canInvite = canDo('admin', 'workspace:invite')
// true
```
**Parameters:**
- `userRole` (WorkspaceRole): User's current role
- `action` (string): Action key (see Permission Matrix)
**Returns:** boolean
---
### React Hooks
#### `useWorkspace(wsId)`
Client-side hook for loading and managing workspace data.
```typescript
const {
workspace, // Workspace | null
members, // WorkspaceMember[]
invites, // WorkspaceInvite[]
userRole, // WorkspaceRole | null
loading, // boolean
error, // string | null
refresh, // () => Promise<void>
canDo // (action: string) => boolean
} = useWorkspace('ws-123')
```
**Returns:**
- `workspace`: Current workspace data
- `members`: List of members
- `invites`: Pending invites
- `userRole`: User's role in this workspace
- `loading`: Data fetch state
- `error`: Any fetch errors
- `refresh`: Manual data refresh function
- `canDo`: Permission checker bound to userRole
---
## 🎨 UI Component: WorkspaceDashboard
Pre-built dashboard component for workspace management.
### Usage
```tsx
import WorkspaceDashboard from '@/components/workspaces/WorkspaceDashboard'
export default function Page({ params }) {
return <WorkspaceDashboard params={{ wsId: params.id }} />
}
```
### Features
- **Member list** with role badges, avatars, status
- **Inline role editor** (hover to reveal)
- **Remove member** button with confirmation
- **Invite form** with email and role selection
- **Pending invites** section with revoke controls
- **Workspace info panel:** Slug, plan, creation date
- **Admin controls:** Transfer ownership, delete, export
- **Responsive layout:** 2-column on desktop, single on mobile
- **Dark theme** with glassmorphism effects
### Props
```typescript
interface WorkspaceDashboardProps {
params: {
wsId: string // Workspace UUID
}
}
```
---
## 🔐 Security Considerations
### Row-Level Security (RLS)
All operations are protected by Supabase RLS policies:
- Users can only see workspaces they're members of
- Only admins/owners can view and manage members
- Only workspace owners can delete workspaces
### Use Service Role Key Carefully
The service role key bypasses RLS. Only use it in server-side contexts (API routes, server actions). **Never expose it to the client.**
### Permission Checks
Always check permissions before showing UI or processing actions:
```typescript
if (!canDo(userRole, 'workspace:invite')) {
throw new Error('Unauthorized')
}
```
### Slug Uniqueness
Slugs are automatically generated and enforced as UNIQUE at the database level. The `uniqueSlug()` function handles collisions.
### Invite Token Security
Tokens are auto-generated UUIDs and stored in the database. Implement email verification for production:
```typescript
// Use Resend or similar to send invite email
await resend.emails.send({
from: 'noreply@company.com',
to: email,
subject: `Join ${workspaceName}`,
html: `<a href="https://app.com/join?token=${token}">Accept invite</a>`
})
```
---
## 📝 TypeScript Interfaces
```typescript
export type WorkspaceRole = 'owner' | 'admin' | 'member' | 'viewer'
export type WorkspacePlan = 'free' | 'pro' | 'enterprise'
export interface Workspace {
id: string
name: string
slug: string
owner_id: string
plan: WorkspacePlan
logo_url: string | null
settings: Record<string, any>
created_at: string
member_count?: number
}
export interface WorkspaceMember {
id: string
workspace_id: string
user_id: string
role: WorkspaceRole
joined_at: string
email: string
name: string | null
avatar_url: string | null
}
export interface WorkspaceInvite {
id: string
workspace_id: string
email: string
role: WorkspaceRole
token: string
invited_by: string | null
accepted_at: string | null
expires_at: string
created_at: string
}
```
---
## 🛠️ Common Patterns
### Create a workspace and invite members
```typescript
const ws = await createWorkspace(userId, 'My Team')
await Promise.all([
inviteMember(ws.id, userId, 'alice@company.com', 'admin'),
inviteMember(ws.id, userId, 'bob@company.com', 'member'),
inviteMember(ws.id, userId, 'charlie@company.com', 'viewer')
])
```
### Check permission before action
```typescript
const role = await getMemberRole(wsId, userId)
if (!canDo(role, 'workspace:invite')) {
throw new Error('Not authorized')
}
// Proceed with action
```
### List all workspaces for user with quick stats
```typescript
const workspaces = await getUserWorkspaces(userId)
const withMemberCounts = await Promise.all(
workspaces.map(async (ws) => ({
...ws,
member_count: (await getWorkspaceMembers(ws.id)).length
}))
)
```
### Accept invite after email link click
```typescript
const invite = await getInviteByToken(token)
if (!invite) {
throw new Error('Invalid or expired invite')
}
const { data: { session } } = await supabase.auth.getSession()
await acceptInvite(token, session.user.id)
```
---
## 🐛 Troubleshooting
### "Only the workspace owner can delete it"
Ensure you're passing the correct `ownerId` that matches the workspace's `owner_id`.
### "Cannot assign owner role via this function"
Use `transferOwnership()` instead of `updateMemberRole()` for owner transfers.
### "Invite is invalid or has expired"
Invites expire after 7 days. Check `expires_at` or re-send the invite.
### RLS policy violation errors
Ensure the authenticated user is a member of the workspace. Check the Supabase RLS policies match the schema comments.
### Unique constraint on (workspace_id, email)
You can only have one pending invite per email per workspace. Upsert a new invite to refresh the token.
---
## 📦 File Structure
```
lib/
├── workspaces.ts # All backend functions & hooks
└── components/
└── workspaces/
└── WorkspaceDashboard.tsx # Dashboard UI component
```
---
## 🚦 Environment Checklist
Before deploying to production:
- [ ] SQL migrations applied to Supabase
- [ ] RLS policies enabled on all tables
- [ ] `NEXT_PUBLIC_SUPABASE_URL` set
- [ ] `NEXT_PUBLIC_SUPABASE_ANON_KEY` set
- [ ] `SUPABASE_SERVICE_ROLE_KEY` set (server-side only)
- [ ] `RESEND_API_KEY` configured (if using email invites)
- [ ] Invite email templates created
- [ ] Ownership transfer UI tested
- [ ] Member removal confirmations implemented
- [ ] Role permissions audit completed
---
## 📄 License
Part of the MarrowStack framework. Use freely in your projects.
---
## 🤝 Contributing
Found a bug or want to improve this block? Contributions welcome!
---
## 📧 Support
For questions or issues, refer to the Supabase documentation or file an issue.