Skip to content

Marrow-Stack/ms-block-team

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

2 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

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.

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.

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.

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.

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

npm install @supabase/supabase-js resend

2. Set Up Environment Variables

# .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)

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)

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.

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.

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.

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.

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).

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.

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.

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').

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.

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).

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.

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).

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.

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.

await revokeInvite('invite-123')

Parameters:

  • inviteId (string): Invite UUID

Returns: Promise (void)


getPendingInvites(wsId)

List all unexpired, unaccepted invites for a workspace.

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.

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.

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.

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

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

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:

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:

// 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

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

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

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

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

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.

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors