“Book Management” app in Umbraco 8 - FadiZahhar/umbraco8showandtell GitHub Wiki
Umbraco 8 prefers PetaPoco attributes on your data model, but for migrations, you mostly just need a matching class for easier use (optional for pure migration).
using NPoco;
namespace HighlyDeveloped.Core.Models
{
[TableName("Books")]
[PrimaryKey("Id", AutoIncrement = true)]
public class Book
{
public int Id { get; set; }
public string Title { get; set; }
public string Author { get; set; }
public int Year { get; set; }
public string ISBN { get; set; }
}
}
Follow the Umbraco migration pattern using MigrationBase
, but be sure to use the recommended helpers for Umbraco 8. Avoid custom SQL for the table creation; always use the Create.Table
API.
using Umbraco.Core.Migrations;
namespace HighlyDeveloped.Core.Migrations
{
public class CreateBookTableMigration : MigrationBase
{
public CreateBookTableMigration(IMigrationContext context) : base(context) { }
public override void Migrate()
{
if (!TableExists("Books"))
{
Create.Table("Books")
.WithColumn("Id").AsInt32().PrimaryKey("PK_Books_Id").Identity()
.WithColumn("Title").AsString(255).NotNullable()
.WithColumn("Author").AsString(255).NotNullable()
.WithColumn("Year").AsInt32().NotNullable()
.WithColumn("ISBN").AsString(50).Nullable()
.Do();
}
}
}
}
- NotNullable for required fields, Nullable for optional.
- Table name is pluralized and matches your POCO.
Component:
using Umbraco.Core.Composing;
using Umbraco.Core.Migrations;
using Umbraco.Core.Scoping;
using Umbraco.Core.Services;
using Umbraco.Core.Logging;
using Umbraco.Core.Migrations.Upgrade;
namespace HighlyDeveloped.Core.Migrations
{
public class BookTableMigrationComponent : IComponent
{
private readonly IScopeProvider _scopeProvider;
private readonly IMigrationBuilder _migrationBuilder;
private readonly IKeyValueService _keyValueService;
private readonly ILogger _logger;
public BookTableMigrationComponent(
IScopeProvider scopeProvider,
IMigrationBuilder migrationBuilder,
IKeyValueService keyValueService,
ILogger logger)
{
_scopeProvider = scopeProvider;
_migrationBuilder = migrationBuilder;
_keyValueService = keyValueService;
_logger = logger;
}
public void Initialize()
{
var migrationPlan = new MigrationPlan("BookTable");
migrationPlan.From(string.Empty)
.To<CreateBookTableMigration>("create-book-table");
var upgrader = new Upgrader(migrationPlan);
upgrader.Execute(_scopeProvider, _migrationBuilder, _keyValueService, _logger);
}
public void Terminate() { }
}
}
Composer:
using HighlyDeveloped.Core.Migrations;
using Umbraco.Core;
using Umbraco.Core.Composing;
namespace HighlyDeveloped.Core.Composers
{
public class BookTableMigrationComposer : IUserComposer
{
public void Compose(Composition composition)
{
composition.Components().Append<BookTableMigrationComponent>();
}
}
}
For CRUD operations, inject the IScopeProvider
into your repository and use Database
via a scope:
using HighlyDeveloped.Core.Models;
using NPoco;
using System.Collections.Generic;
using Umbraco.Core.Scoping;
namespace HighlyDeveloped.Core.Repositories
{
public class BookRepository
{
private readonly IScopeProvider _scopeProvider;
public BookRepository(IScopeProvider scopeProvider)
{
_scopeProvider = scopeProvider;
}
public IEnumerable<Book> GetAll()
{
using (var scope = _scopeProvider.CreateScope())
{
return scope.Database.Fetch<Book>("SELECT * FROM Books");
}
}
public Book GetById(int id)
{
using (var scope = _scopeProvider.CreateScope())
{
return scope.Database.SingleOrDefault<Book>("SELECT * FROM Books WHERE Id = @0", id);
}
}
public void Insert(Book book)
{
using (var scope = _scopeProvider.CreateScope())
{
scope.Database.Insert(book);
scope.Complete();
}
}
public void Update(Book book)
{
using (var scope = _scopeProvider.CreateScope())
{
scope.Database.Update(book);
scope.Complete();
}
}
public void Delete(int id)
{
using (var scope = _scopeProvider.CreateScope())
{
scope.Database.Execute("DELETE FROM Books WHERE Id = @0", id);
scope.Complete();
}
}
}
}
- Always use
TableExists
before table creation. - Use strongly-typed POCOs for working with the table.
- Register all dependencies (repositories/services) via a Composer.
-
Migration Plan name (
BookTable
) must be unique—don’t reuse for other tables.
-
If the table doesn’t appear:
- Check
/App_Data/Logs/UmbracoTraceLog.txt
for errors. - Ensure your DLL is in
/bin
and public classes/namespaces are correct. - Restart the site/app pool after code changes.
- Check
You can follow this approach for any custom table in Umbraco 8, based on the best practices from the official documentation.
- POCO with PetaPoco attributes (optional, but recommended for CRUD).
-
Migration creates the table with
Create.Table
helpers. - Component & Composer run the migration plan on startup.
-
Repository/Service for CRUD using
IScopeProvider
. - Register everything in Composers.
You’re building a Book Management App for your library client. They want to manage their book catalog directly from Umbraco’s backoffice, but outside the content tree—with a modern AngularJS dashboard. You’ll store book data in a custom database table, create a repository for database access (NPoco), expose an API for the UI, manage everything through a service, and register all with dependency injection and a composer. Finally, you’ll tie it all together with a custom dashboard in Umbraco’s UI.
File: /Models/Book.cs
This is the shape of your book object, and will also become your table schema.
File: /Migrations/CreateBookTableMigration.cs
This code runs on startup to create your custom Books
table.
File: /Repositories/BookRepository.cs
All SQL is isolated here for clean separation and testability.
File: /Services/BookService.cs
using System.Collections.Generic;
using YourNamespace.Models;
using YourNamespace.Repositories;
namespace YourNamespace.Services
{
public class BookService
{
private readonly BookRepository _repo;
public BookService(BookRepository repo) { _repo = repo; }
public IEnumerable<Book> GetAll() => _repo.GetAll();
public Book GetById(int id) => _repo.GetById(id);
public void Add(Book book) => _repo.Insert(book);
public void Update(Book book) => _repo.Update(book);
public void Delete(int id) => _repo.Delete(id);
}
}
This is where any complex business rules go. For now, it passes through to the repo.
File: `/Composers/BookServiceComposer.cs
using HighlyDeveloped.Core.Repositories;
using HighlyDeveloped.Core.Services;
using Umbraco.Core;
using Umbraco.Core.Composing;
public class BookServiceComposer : IUserComposer
{
public void Compose(Composition composition)
{
composition.Register<BookService>(Lifetime.Singleton);
// If BookRepository is a dependency, register it too!
composition.Register<BookRepository>(Lifetime.Singleton);
}
}
File: /Controllers/BookApiController.cs
using System.Collections.Generic;
using System.Web.Http;
using Umbraco.Web.WebApi;
using YourNamespace.Models;
using YourNamespace.Services;
namespace YourNamespace.Controllers
{
public class BookApiController : UmbracoAuthorizedApiController
{
private readonly BookService _service;
public BookApiController(BookService service) { _service = service; }
[HttpGet]
public IEnumerable<Book> GetAll() => _service.GetAll();
[HttpGet]
public Book GetById(int id) => _service.GetById(id);
[HttpPost]
public void PostSave(Book book)
{
if (book.Id == 0)
_service.Add(book);
else
_service.Update(book);
}
[HttpPost]
public void PostDelete([FromBody] int id) => _service.Delete(id);
}
}
All CRUD endpoints for the AngularJS UI.
File: /Composers/BookManagerComposer.cs
using HighlyDeveloped.Core.Migrations;
using Umbraco.Core;
using Umbraco.Core.Composing;
namespace HighlyDeveloped.Core.Composers
{
public class BookTableMigrationComposer : IUserComposer
{
public void Compose(Composition composition)
{
composition.Components().Append<BookTableMigrationComponent>();
}
}
}
This registers your repository, service, and ensures the migration runs.
Folder: /App_Plugins/BookManager/
{
"dashboards": [
{
"alias": "bookManagerDashboard",
"view": "/App_Plugins/BookManager/dashboard/dashboard.html",
"sections": [ "content" ],
"weight": 10,
"label": "Book Manager"
}
],
"javascript": [
"~/App_Plugins/BookManager/dashboard/dashboard.controller.js"
],
"css": [
"https://www.w3schools.com/w3css/5/w3.css"
]
}
<div ng-controller="BookManagerDashboardController as vm">
<h2>Book Manager</h2>
<button class="btn" ng-click="vm.newBook()">Add Book</button>
<table class="table">
<tr>
<th>Title</th>
<th>Author</th>
<th>Year</th>
<th>ISBN</th>
<th>Actions</th>
</tr>
<tr ng-repeat="book in vm.books">
<td>{{book.title}}</td>
<td>{{book.author}}</td>
<td>{{book.year}}</td>
<td>{{book.isbn}}</td>
<td>
<button class="btn btn-link" ng-click="vm.editBook(book)">Edit</button>
<button class="btn btn-link" ng-click="vm.deleteBook(book.id)">Delete</button>
</td>
</tr>
</table>
<!-- Add/Edit Form -->
<div ng-if="vm.editing">
<form novalidate>
<label>Title: <input ng-model="vm.currentBook.title" required /></label><br>
<label>Author: <input ng-model="vm.currentBook.author" required /></label><br>
<label>Year: <input ng-model="vm.currentBook.year" type="number" required /></label><br>
<label>ISBN: <input ng-model="vm.currentBook.isbn" required /></label><br>
<button type="button" ng-click="vm.saveBook()" class="btn btn-success" ng-disabled="bookForm.$invalid">Save</button>
<button type="button" class="btn" ng-click="vm.cancel()">Cancel</button>
</form>
</div>
</div>
angular.module("umbraco")
.controller("BookManagerDashboardController", function ($scope, $http) {
var vm = this;
vm.books = [];
vm.editing = false;
vm.currentBook = {};
vm.loadBooks = function () {
$http.get("/umbraco/backoffice/api/BookApi/GetAll").then(function (res) {
vm.books = res.data;
console.log("here", res.data);
});
};
vm.newBook = function () {
vm.editing = true;
vm.currentBook = {};
};
vm.editBook = function (book) {
vm.editing = true;
vm.currentBook = angular.copy(book);
};
vm.saveBook = function () {
$http.post("/umbraco/backoffice/api/BookApi/PostSave", vm.currentBook).then(function () {
vm.editing = false;
vm.loadBooks();
});
};
vm.deleteBook = function (id) {
$http.post("/umbraco/backoffice/api/BookApi/PostDelete", id).then(function () {
vm.loadBooks();
});
};
vm.cancel = function () {
vm.editing = false;
};
vm.loadBooks();
});
---
## 🌎 8. ** Public API Route**
File: `/Controllers/BooksPublicController.cs`
```csharp
using System.Collections.Generic;
using System.Web.Http;
using Umbraco.Web.WebApi;
using YourNamespace.Models;
using YourNamespace.Services;
public class BooksPublicController : UmbracoApiController
{
private readonly BookService _service;
public BooksPublicController(BookService service) { _service = service; }
[HttpGet]
public IEnumerable<Book> GetAllBooks() => _service.GetAll();
}
Now, anyone can fetch books at /umbraco/api/BooksPublic/GetAllBooks
.
- Custom Controller: BookApiController (CRUD API)
- Service: BookService (business logic)
- Repository: BookRepository (NPoco/SQL)
- Custom Routing: Public endpoint for books
- Dashboard UI: Modern AngularJS interface
- Composer: Registers DI + migration
- Dependency Injection: Everywhere, clean code!
using System.ComponentModel.DataAnnotations;
using NPoco;
namespace HighlyDeveloped.Core.Models
{
[TableName("Books")]
[PrimaryKey("Id", AutoIncrement = true)]
public class Book
{
public int Id { get; set; }
[Required]
[StringLength(150)]
public string Title { get; set; }
[Required]
[StringLength(100)]
public string Author { get; set; }
[Range(1450, 2100)] // Reasonable year range for books
public int Year { get; set; }
[Required]
[RegularExpression(@"^\d{3}-\d{10}$", ErrorMessage = "ISBN must be in the format 123-1234567890")]
public string ISBN { get; set; }
}
}
Make sure your Book
model (as shown earlier) uses attributes like [Required]
, [StringLength]
, etc.
Update your PostSave
method to check for validation errors using ModelState.IsValid
. If invalid, return a BadRequest
with error details.
Here’s the revised controller:
using System.Collections.Generic;
using System.Web.Http;
using Umbraco.Web.WebApi;
using HighlyDeveloped.Core.Models;
using HighlyDeveloped.Core.Services;
namespace HighlyDeveloped.Core.Controllers
{
public class BookApiController : UmbracoAuthorizedApiController
{
private readonly BookService _service;
public BookApiController(BookService service) { _service = service; }
[HttpGet]
public IEnumerable<Book> GetAll() => _service.GetAll();
[HttpGet]
public Book GetById(int id) => _service.GetById(id);
[HttpPost]
public IHttpActionResult PostSave([FromBody] Book book)
{
// 1. ModelState is automatically populated based on DataAnnotations
if (!ModelState.IsValid)
{
// Return all validation errors to the caller
return BadRequest(ModelState);
}
if (book.Id == 0)
_service.Add(book);
else
_service.Update(book);
return Ok(book);
}
[HttpPost]
public IHttpActionResult PostDelete([FromBody] int id)
{
_service.Delete(id);
return Ok();
}
}
}
-
If you send invalid JSON (e.g., missing required fields, wrong format), the
ModelState
will have errors. -
The API will return a 400 Bad Request with a list of validation messages.
{
"Message": "The request is invalid.",
"ModelState": {
"book.Title": ["The Title field is required."],
"book.ISBN": ["ISBN must be in the format 123-1234567890"]
}
}
-
Make sure your API calls use
Content-Type: application/json
. -
Test by sending incomplete or wrong data to verify you get validation errors.
Step | What to do |
---|---|
Add DataAnnotations | In your Book model |
Check ModelState in Action | In your [HttpPost] methods |
Return IHttpActionResult | For better API responses and errors |
This is the standard/best-practice way to do validation with Web API and UmbracoAuthorizedApiController!
Here’s a complete sample AngularJS dashboard page that includes:
- Client-side validation (immediate feedback)
- API/server-side validation error display
- Basic CRUD flow
- Uses your controller pattern (
BookManagerDashboardController as bookManager
)
You can copy-paste this directly into your Umbraco custom dashboard HTML.
<div ng-controller="BookManagerDashboardController as bookManager">
<h2>Book Manager</h2>
<button class="btn btn-success mb-3" ng-click="bookManager.newBook()">Add New Book</button>
<!-- Book List -->
<table class="table table-striped" ng-if="!bookManager.editing">
<thead>
<tr>
<th>Title</th>
<th>Author</th>
<th>Year</th>
<th>ISBN</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr ng-repeat="book in bookManager.books track by book.Id">
<td>{{book.Title}}</td>
<td>{{book.Author}}</td>
<td>{{book.Year}}</td>
<td>{{book.ISBN}}</td>
<td>
<button class="btn btn-primary btn-sm" ng-click="bookManager.editBook(book)">Edit</button>
<button class="btn btn-danger btn-sm" ng-click="bookManager.deleteBook(book.Id)">Delete</button>
</td>
</tr>
</tbody>
</table>
<!-- Book Edit/Create Form -->
<form name="bookForm"
novalidate
ng-submit="bookManager.saveBook(bookForm)"
ng-if="bookManager.editing"
class="mb-4">
<h4 ng-if="!bookManager.currentBook.Id">Add Book</h4>
<h4 ng-if="bookManager.currentBook.Id">Edit Book</h4>
<div class="form-group mb-2">
<label>Title:</label>
<input type="text"
name="title"
ng-model="bookManager.currentBook.Title"
ng-required="true"
ng-maxlength="150"
class="form-control"/>
<span ng-show="bookForm.title.$touched && bookForm.title.$error.required" class="text-danger">
Title is required.
</span>
<span ng-show="bookForm.title.$error.maxlength" class="text-danger">
Title is too long (max 150).
</span>
</div>
<div class="form-group mb-2">
<label>Author:</label>
<input type="text"
name="author"
ng-model="bookManager.currentBook.Author"
ng-required="true"
ng-maxlength="100"
class="form-control"/>
<span ng-show="bookForm.author.$touched && bookForm.author.$error.required" class="text-danger">
Author is required.
</span>
<span ng-show="bookForm.author.$error.maxlength" class="text-danger">
Author is too long (max 100).
</span>
</div>
<div class="form-group mb-2">
<label>Year:</label>
<input type="number"
name="year"
ng-model="bookManager.currentBook.Year"
ng-required="true"
min="1450"
max="2100"
class="form-control"/>
<span ng-show="bookForm.year.$touched && bookForm.year.$error.required" class="text-danger">
Year is required.
</span>
<span ng-show="bookForm.year.$error.min || bookForm.year.$error.max" class="text-danger">
Year must be between 1450 and 2100.
</span>
</div>
<div class="form-group mb-2">
<label>ISBN:</label>
<input type="text"
name="isbn"
ng-model="bookManager.currentBook.ISBN"
ng-required="true"
ng-pattern="/^\d{3}-\d{10}$/"
class="form-control"/>
<span ng-show="bookForm.isbn.$touched && bookForm.isbn.$error.required" class="text-danger">
ISBN is required.
</span>
<span ng-show="bookForm.isbn.$error.pattern" class="text-danger">
ISBN must be in the format 123-1234567890.
</span>
</div>
<!-- Display server-side (API) validation errors -->
<div ng-if="bookManager.validationErrors && bookManager.validationErrors.length > 0" class="alert alert-danger">
<ul>
<li ng-repeat="error in bookManager.validationErrors track by $index">{{ error }}</li>
</ul>
</div>
<button type="submit"
class="btn btn-primary"
ng-disabled="bookForm.$invalid">
Save
</button>
<button type="button" class="btn btn-secondary" ng-click="bookManager.cancel()">Cancel</button>
</form>
</div>
You should slightly update your AngularJS controller to support the form reference:
angular.module("umbraco")
.controller("BookManagerDashboardController", function ($scope, $http) {
var vm = this;
vm.books = [];
vm.editing = false;
vm.currentBook = {};
vm.validationErrors = null;
vm.loadBooks = function () {
$http.get("/umbraco/backoffice/api/BookApi/GetAll").then(function (res) {
vm.books = res.data;
});
};
vm.newBook = function () {
vm.editing = true;
vm.currentBook = {};
vm.validationErrors = null;
};
vm.editBook = function (book) {
vm.editing = true;
vm.currentBook = angular.copy(book);
vm.validationErrors = null;
};
// Accept the form as a parameter so we can touch all fields if needed
vm.saveBook = function (form) {
// Touch all fields if invalid (shows validation errors)
if (form.$invalid) {
angular.forEach(form.$error, function (fields) {
angular.forEach(fields, function (field) {
field.$setTouched();
});
});
return;
}
$http.post("/umbraco/backoffice/api/BookApi/PostSave", vm.currentBook)
.then(function () {
vm.editing = false;
vm.loadBooks();
vm.validationErrors = null;
})
.catch(function (err) {
if (err.data && err.data.ModelState) {
var errors = [];
angular.forEach(err.data.ModelState, function (messages) {
messages.forEach(function (message) {
errors.push(message);
});
});
vm.validationErrors = errors;
} else if (err.data && err.data.Message) {
vm.validationErrors = [err.data.Message];
} else {
vm.validationErrors = ["An unknown error occurred."];
}
});
};
vm.deleteBook = function (id) {
$http.post("/umbraco/backoffice/api/BookApi/PostDelete", id).then(function () {
vm.loadBooks();
});
};
vm.cancel = function () {
vm.editing = false;
vm.validationErrors = null;
};
vm.loadBooks();
});
- Client-side validation: Instant errors shown (required, max-length, pattern, etc.), and "Save" is disabled when invalid.
- On submit: If the form is valid, data is posted to your API.
- Server/API errors: If your API returns validation errors, they're shown as a list at the top of the form.
-
Edit/New: Same form is used for both, handled by checking
bookManager.currentBook.Id
.
This approach is clean, scalable, and fully covers both UX and API contract!
First, update your BookService
and BookRepository
to support paging and searching.
public (IEnumerable<Book> Books, int TotalCount) Search(string term, int page, int pageSize)
{
using (var scope = _scopeProvider.CreateScope())
{
var where = string.IsNullOrWhiteSpace(term)
? ""
: "WHERE Title LIKE @0 OR Author LIKE @0 OR ISBN LIKE @0";
var query = $"SELECT * FROM Books {where} ORDER BY Id DESC OFFSET @1 ROWS FETCH NEXT @2 ROWS ONLY";
var countQuery = $"SELECT COUNT(*) FROM Books {where}";
var param = string.IsNullOrWhiteSpace(term) ? new object[] { } : new object[] { $"%{term}%" };
var books = scope.Database.Fetch<Book>(query, param.Append((page - 1) * pageSize).Append(pageSize).ToArray());
var count = scope.Database.ExecuteScalar<int>(countQuery, param);
return (books, count);
}
}
Note: You may need to adjust the SQL syntax if your Umbraco instance runs on a non-SQL Server database.
public (IEnumerable<Book> Books, int TotalCount) Search(string term, int page, int pageSize)
=> _repo.Search(term, page, pageSize);
[HttpGet]
public object Search(string term = "", int page = 1, int pageSize = 10)
{
var result = _service.Search(term, page, pageSize);
return new
{
books = result.Books,
totalCount = result.TotalCount
};
}
<input ng-model="vm.searchTerm" ng-change="vm.searchBooks()" placeholder="Search books..." class="umb-search" />
<table class="table">
<!-- ... as before ... -->
</table>
<ul class="pagination">
<li ng-class="{disabled: vm.page === 1}">
<a href ng-click="vm.goToPage(vm.page-1)">«</a>
</li>
<li ng-repeat="p in [].constructor(vm.totalPages) track by $index"
ng-class="{active: $index+1 === vm.page}">
<a href ng-click="vm.goToPage($index+1)">{{$index+1}}</a>
</li>
<li ng-class="{disabled: vm.page === vm.totalPages}">
<a href ng-click="vm.goToPage(vm.page+1)">»</a>
</li>
</ul>
angular.module("umbraco")
.controller("BookManagerDashboardController", function($scope, $http) {
var vm = this;
vm.books = [];
vm.editing = false;
vm.currentBook = {};
vm.searchTerm = "";
vm.page = 1;
vm.pageSize = 10;
vm.totalCount = 0;
vm.totalPages = 1;
vm.searchBooks = function() {
$http.get("/umbraco/backoffice/api/BookApi/Search", {
params: {
term: vm.searchTerm,
page: vm.page,
pageSize: vm.pageSize
}
}).then(function(res) {
vm.books = res.data.books;
vm.totalCount = res.data.totalCount;
vm.totalPages = Math.ceil(vm.totalCount / vm.pageSize);
});
};
vm.goToPage = function(page) {
if (page < 1 || page > vm.totalPages) return;
vm.page = page;
vm.searchBooks();
};
// Override loadBooks to use search
vm.loadBooks = function() {
vm.page = 1;
vm.searchBooks();
};
// Call search on start
vm.loadBooks();
// The rest (edit, new, save, delete, cancel) remain as before
});
Add an endpoint to your API controller.
[HttpGet]
public HttpResponseMessage ExportCsv()
{
var books = _service.GetAll();
var sb = new StringBuilder();
sb.AppendLine("Id,Title,Author,Year,ISBN");
foreach(var book in books)
sb.AppendLine($"{book.Id},\"{book.Title}\",\"{book.Author}\",{book.Year},\"{book.ISBN}\"");
var result = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(sb.ToString(), Encoding.UTF8, "text/csv")
};
result.Content.Headers.ContentDisposition = new System.Net.Http.Headers.ContentDispositionHeaderValue("attachment")
{
FileName = "books-export.csv"
};
return result;
}
Add to your HTML:
<button class="btn" ng-click="vm.exportBooks()">Export CSV</button>
Add to your controller:
vm.exportBooks = function() {
var url = "/umbraco/backoffice/api/BookApi/ExportCsv";
window.open(url, '_blank');
};
- Search: Type in the box to filter by title, author, or ISBN.
- Paging: Use pagination to browse large book catalogs.
- Export: Click “Export CSV” to download the full book list for Excel or reports.
Umbraco 8 exposes the current backoffice user via UmbracoContext.Security.CurrentUser
.
using Umbraco.Core.Security; // Needed for CurrentUser
[HttpPost]
public IHttpActionResult PostDelete([FromBody] int id)
{
var user = UmbracoContext.Security.CurrentUser;
if (!user.Groups.Any(g => g.Alias == "admin"))
return Unauthorized();
_service.Delete(id);
return Ok();
}
[HttpGet]
public HttpResponseMessage ExportCsv()
{
var user = UmbracoContext.Security.CurrentUser;
if (!user.Groups.Any(g => g.Alias == "admin"))
return Request.CreateResponse(HttpStatusCode.Forbidden, "Not allowed.");
// ...rest of the export code...
}
- The default built-in group is “admin”, but you can check for any custom group alias.
- You can also allow multiple groups by checking with
.Any(g => allowedGroups.Contains(g.Alias))
.
You can expose the current user’s groups via a new API endpoint.
[HttpGet]
public IEnumerable<string> GetCurrentUserGroups()
{
var user = UmbracoContext.Security.CurrentUser;
return user.Groups.Select(g => g.Alias);
}
Add a property for permissions:
vm.userGroups = [];
vm.checkPermissions = function() {
$http.get("/umbraco/backoffice/api/BookApi/GetCurrentUserGroups").then(function(res) {
vm.userGroups = res.data;
});
};
vm.checkPermissions();
vm.canDelete = function() {
return vm.userGroups.indexOf('admin') !== -1;
};
vm.canExport = function() {
return vm.userGroups.indexOf('admin') !== -1;
};
<button class="btn" ng-if="vm.canExport()" ng-click="vm.exportBooks()">Export CSV</button>
<!-- ... -->
<button class="btn btn-link" ng-if="vm.canDelete()" ng-click="vm.deleteBook(book.id)">Delete</button>
For full endpoint protection, you can use Umbraco’s UserAuthorizeAttribute
and a custom attribute, or check at the start of every protected action as shown above.
Example: Custom Authorize Attribute
public class AdminOnlyAttribute : AuthorizeAttribute
{
protected override bool IsAuthorized(HttpActionContext actionContext)
{
var user = Umbraco.Web.Composing.Current.UmbracoContext.Security.CurrentUser;
return user != null && user.Groups.Any(g => g.Alias == "admin");
}
}
Then decorate your controller actions:
[AdminOnly]
public IHttpActionResult PostDelete([FromBody] int id) { ... }
- Backend: Only users in the “admin” group can export/delete books.
- Frontend: UI only shows Export/Delete to admins.
- You can extend this for any group/permission logic needed.
- A configuration file (for easy mapping of which group can do what)
- API controller logic to check permissions per action
- AngularJS UI that asks the API what the user can do
Let’s keep things flexible:
Create /App_Plugins/BookManager/permissions.config.json
in your solution.
{
"add": ["admin", "editor"],
"edit": ["admin", "editor"],
"delete": ["admin"],
"export": ["admin", "manager"]
}
You can update this file to add more roles/groups per action anytime!
Create a helper to read the config file and check if the current user can perform a given action.
/Helpers/PermissionHelper.cs
using Newtonsoft.Json;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Umbraco.Core.Composing;
using Umbraco.Web;
namespace YourNamespace.Helpers
{
public static class PermissionHelper
{
private static Dictionary<string, List<string>> _permissions;
public static void LoadPermissions()
{
var configPath = System.Web.Hosting.HostingEnvironment.MapPath("~/App_Plugins/BookManager/permissions.config.json");
var json = File.ReadAllText(configPath);
_permissions = JsonConvert.DeserializeObject<Dictionary<string, List<string>>>(json);
}
public static bool UserHasPermission(string action, IEnumerable<string> userGroups)
{
if (_permissions == null) LoadPermissions();
if (!_permissions.ContainsKey(action)) return false;
return userGroups.Any(g => _permissions[action].Contains(g));
}
}
}
Update each endpoint to check permissions by action name:
using YourNamespace.Helpers; // Add this using
[HttpGet]
public object GetPermissions()
{
var user = UmbracoContext.Security.CurrentUser;
var groups = user.Groups.Select(g => g.Alias);
return new
{
add = PermissionHelper.UserHasPermission("add", groups),
edit = PermissionHelper.UserHasPermission("edit", groups),
delete = PermissionHelper.UserHasPermission("delete", groups),
export = PermissionHelper.UserHasPermission("export", groups)
};
}
[HttpPost]
public IHttpActionResult PostSave(Book book)
{
var user = UmbracoContext.Security.CurrentUser;
var groups = user.Groups.Select(g => g.Alias);
if (book.Id == 0 && !PermissionHelper.UserHasPermission("add", groups))
return Unauthorized();
if (book.Id != 0 && !PermissionHelper.UserHasPermission("edit", groups))
return Unauthorized();
if (book.Id == 0)
_service.Add(book);
else
_service.Update(book);
return Ok();
}
[HttpPost]
public IHttpActionResult PostDelete([FromBody] int id)
{
var user = UmbracoContext.Security.CurrentUser;
var groups = user.Groups.Select(g => g.Alias);
if (!PermissionHelper.UserHasPermission("delete", groups))
return Unauthorized();
_service.Delete(id);
return Ok();
}
[HttpGet]
public HttpResponseMessage ExportCsv()
{
var user = UmbracoContext.Security.CurrentUser;
var groups = user.Groups.Select(g => g.Alias);
if (!PermissionHelper.UserHasPermission("export", groups))
return Request.CreateResponse(HttpStatusCode.Forbidden, "Not allowed.");
// ...export code as before...
}
In your AngularJS controller:
vm.permissions = { add: false, edit: false, delete: false, export: false };
vm.getPermissions = function() {
$http.get("/umbraco/backoffice/api/BookApi/GetPermissions").then(function(res) {
vm.permissions = res.data;
});
};
vm.getPermissions();
vm.canAdd = function() { return vm.permissions.add; };
vm.canEdit = function() { return vm.permissions.edit; };
vm.canDelete = function() { return vm.permissions.delete; };
vm.canExport = function() { return vm.permissions.export; };
In your dashboard.html:
<button class="btn" ng-if="vm.canAdd()" ng-click="vm.newBook()">Add Book</button>
<button class="btn" ng-if="vm.canExport()" ng-click="vm.exportBooks()">Export CSV</button>
<!-- Table -->
<tr ng-repeat="book in vm.books">
<!-- ... -->
<td>
<button class="btn btn-link" ng-if="vm.canEdit()" ng-click="vm.editBook(book)">Edit</button>
<button class="btn btn-link" ng-if="vm.canDelete()" ng-click="vm.deleteBook(book.id)">Delete</button>
</td>
</tr>
- permissions.config.json controls allowed groups for each action
- PermissionHelper centralizes all permission logic
- API controller checks permission per action for every call
- Frontend queries and hides/disables UI elements as appropriate
This pattern is clean, maintainable, and scalable. Update the config file—no code deploy needed for permission tweaks!