# 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): 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 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 } ``` ### 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: `Accept invite` }) ``` --- ## ๐Ÿ“ 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 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.