Structured Content 2022: Join our conference to explore fresh perspectives on content and digital experiences →
March 09, 2022

Customising the Studio for Users and Roles

By Simeon Griggs & Rune Botten

The promise of an excellent Sanity Studio is that Developers craft uniquely useful experiences for Authors.

This guide covers how to customise some of the most commonly used parts of the Studio on a per-user or per-role basis.

Desk Structure

An Administrator in your Studio likely needs visibility across all of your content types. But an Author that is only concerned with a specific domain could be given a simpler experience with less options.

With Desk Structure configured in your Studio, we can subscribe to the userStore in order to find out details about the current user.

The example below will display different items to a user whose roles include one with the name "administrator".

// ./src/desk-structure.js

import S from '@sanity/desk-tool/structure-builder'
import userStore from 'part:@sanity/base/user'
// remember to add rxjs/operators to your dependencies with npm or yarn
import {map} from 'rxjs/operators'

const authorItems = [
  S.documentTypeListItem('article').title('Articles'),
  S.documentTypeListItem('author').title('Authors'),
]

const adminOnly = [
  S.documentTypeListItem('project').title('Projects'),
  S.documentTypeListItem('setting').title('Settings'),
]

export default () =>
  userStore.me.pipe(
    map((user) => {
      const title = `${user.name} Content`
      const userIsAdmin =  user.roles.find(
        (role) => role.name === `administrator`
      );
      const items = userIsAdmin
        ? [...authorItems, S.divider(), ...adminOnly]
        : authorItems;

      return S.list().title(title).items(items)
    })
  )

In addition to customising which Desk Structure items are shown, we can customise what items are shown in a Document List based on GROQ's identity() function.

Protip

identity() is the ID of the User performing the query. Super-useful in the Studio!

For example, perhaps instead of listing all author documents, you only want the Author to see and edit their own document.

S.listItem()
  .id('profile')
  .title('My Profile')
  .child(() =>
    S.documentList('author')
      .title('My Profile')
      .schemaType('author')
      .filter('_type == "author" && userId == identity()')
  ),

However, this will only work if your author documents contain a correct value in a userId field, let's fix that!

Gotcha

Hiding documents in Desk Structure will not remove them from search results in the Studio or in Reference fields. Apply additional filtering to those where possible.

Initial Values

Adding Sanity User ID's to documents can make them easier to query with GROQ using identity()

Using Initial Value Templates you can create individual templates to populate new documents with static or dynamic values.

Using a similar function to the one above, we can create a template for article type documents to pre-fill an author field with the User ID.

import T from '@sanity/base/initial-value-template-builder'
import userStore from 'part:@sanity/base/user'

const getCurrentUser = () => {
  let userDetails

  userStore.me.subscribe((user) => {
    userDetails = user.id ? user : undefined
  })

  return userDetails
}

const defaults = T.defaults()

export default [
  // Filter out the default `author` template
  ...defaults.filter((template) => template.spec.id !== `author`),

  // Insert our custom template so all `author` documents have a `userId` field
  T.template({
    id: 'authorWithId',
    title: 'Author with ID',
    schemaType: 'author',
    value: {
      userId: getCurrentUser().id,
    },
  }),
]

You might also like to...

  • Make the author field readOnly so the data is not corrupted
  • Use a plugin to visualise the User ID with an avatar and name

A note on "Authors" vs "Users"

It is possible and likely your schema will include something like an author type document. Or a greater abstraction; a person document that can be assigned a value of "author" either as a string or reference.

These are different to Sanity Users, and that's fine! An author document likely contains much more details about that person than their Sanity User object does – like biography, contact and social details.

So instead of adding a User ID to every article, page, etc. Add it to a single author document which is connected to those types by a reference.

This way the User ID is only assigned to a single document, and a query like this will resolve all of their connected documents based on who is querying them:

*[_type in ["article", "page"] && author->userId == identity()]

Gotcha

An exception to this is you need to lock an entire document at the Permissions level based on a User ID, as Content Resources cannot resolve references. See the section on Permissions below.

Creating new documents

Now while we have hidden sections of the Desk Structure from specific Users, they will still be able to create new documents of all types from the Navbar (next to the search box).

In the section above we customised Initial Value Templates to remove the default new author document template. We could use the same logic to remove specific document types from this menu by filtering the T.defaults() as below

import T from '@sanity/base/initial-value-template-builder'
import userStore from 'part:@sanity/base/user'

const getCurrentUser = () => {
  let userDetails

  userStore.me.subscribe((user) => {
    userDetails = user.id ? user : undefined
  })

  return userDetails
}

const defaults = T.defaults()
const currentUser = getCurrentUser()
const userIsAdmin = currentUser.roles.find((role) => role.name === `administrator`)

export default [
  ...defaults
    // Filter out the default `author` template for the custom one below
    .filter((template) => template.spec.id !== `author`)
    // Only administrators can create `project` and `setting` documents
    .filter((template) =>
      userIsAdmin ? true : ![`project`, `setting`].includes(template.spec.id)
    ),

  // Insert our custom template so all `author` documents have a `userId` field
  T.template({
    id: 'authorWithId',
    title: 'Author with ID',
    schemaType: 'author',
    value: {
      userId: currentUser.id,
    },
  }),
]

Hidden and Read-only Document Fields

Fortunately the hidden and readOnly properties in schema have direct access to a currentUser variable.

Here we're hiding a price field to any user without the administrator role.

{
  name: 'price',
  type: 'number',
  hidden: ({currentUser}) => currentUser.roles
    .find((role) => role.name !== 'administrator'),
}

readOnly has the same currentUser property and it can even be added at the document level to set all fields to readOnly.

In this example we'll lock the document to any user that is not an administrator or editor.

export default {
  name: 'article',
  type: 'document',
  readOnly: ({currentUser}) => !currentUser.roles
    .find((role) => ['administrator', 'editor'].includes(role.name)),
  fields: [
    // All your fields...
  ],
}

Protip

Setting a field hidden will not remove its value from the Inspect panel, so Users will still be able to see the value of the field.

Making fields readOnly will only protect the field in the Studio, a sufficiently motivated User could still write to the field with an API call.

Document Actions

Sanity's Roles and Permissions API will take care of ensuring that built-in Document Actions like Publish or Delete cannot be clicked by Users that lack those permissions.

But for your custom Actions, you'll need to inspect the current user to selectively hide or disable the Action.

Because Document Actions happen inside React scope, we can move our userStore logic into a custom hook.

Here's a code example that hides the custom Action for anyone other than Administrators or Editors. And disables the button for anyone other than Administrators.

import userStore from 'part:@sanity/base/user'
import { useEffect, useState } from 'react'

export function useCurrentUser() {
  const [user, setUser] = useState()

  useEffect(() => {
    userStore.currentUser.subscribe(e => setUser(e.user))
  }, [])

  if (!user) {
    return {}
  }

  return user
}

export default function CreateMilestone(props) {
  const user = useCurrentUser();

  // Hide the button for users who are not "editor" or "administrator"
  if (
    !user?.roles?.map((role) => ["administrator", "editor"].includes(role.name))
  ) {
    return null;
  }

  const isAdmin = user.roles.find((role) => role.name === "administrator");

  // Disable the button for non-"administrator" users
  return {
    disabled: !isAdmin,
    label: "Create Milestone",
    onHandle: async () => {
      // ...your function logic
      props.onComplete();
    },
  };
}

Custom Roles and Permissions

On Enterprise plans, Studio experience can be customised with Content Resources.

Going back to our userId example earlier, it's possible write a rule that would only give access to users editing an author document other than the one with their userId with the following rule:

_type == "article" && userId == identity()

Because Content Resources cannot dereference fields, this userId would also need to be present on any document type you wish to restrict access to in the same way.

The same logic can filter document lists, search and reference fields at the permission level. By restricting view access to documents that do not have your userId.

Gotcha

All Published documents are viewable in a Public Dataset. If you're trying to restrict the ability to View documents using Permissions, you must also switch to a Private Dataset.

Conclusion

Being able to configure every part of the Studio into a unique experience for specific user groups allows you to place gentle guardrails around authors to ensure they have the most guided and pleasant experience possible.

Other guides by authors