@specscreen/backoffice-core
v0.2.0
Published
Reusable backoffice framework with auth and RBAC for React/Next.js
Readme
@specscreen/backoffice-core
A reusable backoffice framework with authentication, RBAC, and a consistent admin UI for React / Next.js.
Installation
npm install @specscreen/backoffice-coreInclude the stylesheet once in your app (Next.js: app/layout.tsx, Vite: main.tsx):
import "@specscreen/backoffice-core/styles";Quick Start
1. Implement the providers
// authProvider.ts
import type { AuthProvider } from "@specscreen/backoffice-core";
export const authProvider: AuthProvider = {
async login({ email, password }) {
const res = await fetch("/api/auth/login", {
method: "POST",
body: JSON.stringify({ email, password }),
});
if (!res.ok) throw new Error("Invalid credentials");
},
async logout() {
await fetch("/api/auth/logout", { method: "POST" });
},
async checkAuth() {
const res = await fetch("/api/auth/me");
return res.ok;
},
async getUser() {
const res = await fetch("/api/auth/me");
if (!res.ok) return null;
return res.json(); // { id, email, roles, permissions }
},
};// dataProvider.ts
import type { DataProvider } from "@specscreen/backoffice-core";
export const dataProvider: DataProvider = {
async getList(resource, params) {
const res = await fetch(`/api/${resource}`);
const data = await res.json();
return { data, total: data.length };
},
async getOne(resource, id) {
const res = await fetch(`/api/${resource}/${id}`);
return res.json();
},
async create(resource, data) {
const res = await fetch(`/api/${resource}`, {
method: "POST",
body: JSON.stringify(data),
});
return res.json();
},
async update(resource, id, data) {
const res = await fetch(`/api/${resource}/${id}`, {
method: "PUT",
body: JSON.stringify(data),
});
return res.json();
},
async delete(resource, id) {
await fetch(`/api/${resource}/${id}`, { method: "DELETE" });
},
};2. Define your config
// config.ts
import { Users, FileText, Settings } from "lucide-react";
import type { BackofficeConfig } from "@specscreen/backoffice-core";
export const config: BackofficeConfig = {
appName: "My Backoffice",
sidebarGroups: [
{
label: "Platform",
resources: [
{
name: "users",
label: "Users",
path: "/users",
icon: Users,
list: UsersPage,
meta: {
requiredPermissions: ["users.read"],
},
},
{
name: "posts",
label: "Posts",
path: "/posts",
icon: FileText,
list: PostsPage,
meta: {
requiredRoles: ["editor", "admin"],
},
},
],
},
],
sidebarFooterLinks: [
// Navigates to a route
{ label: "Analytics", path: "/analytics", icon: BarChart },
// Opens a modal inline — no route change
{ label: "Settings", icon: Settings, onClick: () => setSettingsOpen(true) },
],
};3. Mount the app
Next.js App Router (app/layout.tsx)
import {
BackofficeApp,
type BackofficeAppProps,
} from "@specscreen/backoffice-core";
import "@specscreen/backoffice-core/styles";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { authProvider } from "@/lib/authProvider";
import { dataProvider } from "@/lib/dataProvider";
import { config } from "@/lib/config";
// Adapter: wraps Next.js <Link> into the framework's NavLink shape
const NavLink = ({ href, children, className }: any) => (
<Link href={href} className={className}>
{children}
</Link>
);
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
const currentPath = usePathname();
return (
<html lang="en">
<body>
<BackofficeApp
config={config}
authProvider={authProvider}
dataProvider={dataProvider}
NavLink={NavLink}
currentPath={currentPath}
>
{children}
</BackofficeApp>
</body>
</html>
);
}Vite / React Router
import { BackofficeApp } from "@specscreen/backoffice-core";
import "@specscreen/backoffice-core/styles";
import { Link, useLocation, Outlet } from "react-router-dom";
import { authProvider } from "./lib/authProvider";
import { dataProvider } from "./lib/dataProvider";
import { config } from "./lib/config";
const NavLink = ({ href, children, className }: any) => (
<Link to={href} className={className}>
{children}
</Link>
);
export function App() {
const { pathname } = useLocation();
return (
<BackofficeApp
config={config}
authProvider={authProvider}
dataProvider={dataProvider}
NavLink={NavLink}
currentPath={pathname}
>
<Outlet />
</BackofficeApp>
);
}Authentication
AuthProvider interface
| Method | Signature | Description |
| ----------- | --------------------------------------- | ----------------------------------------------- |
| login | ({ email, password }) → Promise<void> | Throw on failure |
| logout | () → Promise<void> | Clear session |
| checkAuth | () → Promise<boolean> | Return false (never throw) on invalid session |
| getUser | () → Promise<User \| null> | Return null if unauthenticated |
Boot flow
App mount
→ checkAuth() true → getUser() → render app
false → render <LoginPage />useAuth hook
import { useAuth } from "@specscreen/backoffice-core";
function MyComponent() {
const { user, isAuthenticated, isLoading, login, logout } = useAuth();
return <p>Logged in as {user?.email}</p>;
}| Property | Type | Description |
| ----------------- | ----------------------------------- | ---------------------------------- |
| user | User \| null | Current authenticated user |
| isAuthenticated | boolean | Session is active |
| isLoading | boolean | Auth boot is in progress |
| error | string \| null | Last auth error message |
| login | (email, password) → Promise<void> | Delegates to authProvider.login |
| logout | () → Promise<void> | Delegates to authProvider.logout |
| refreshUser | () → Promise<void> | Re-fetch user profile |
RBAC
Permission model
// User object returned by authProvider.getUser()
{
id: "u1",
email: "[email protected]",
roles: ["admin", "editor"],
permissions: ["users.read", "users.create", "posts.publish"]
}- Roles — coarse-grained team/tier (
"admin","editor") - Permissions — fine-grained actions (
"users.delete","posts.publish") - Both are optional strings — the framework never hardcodes meaning
- Your backend is the source of truth; the UI is decoration only
usePermissions hook
import { usePermissions } from "@specscreen/backoffice-core";
function ActionsBar() {
const { can, canAny, canAll, hasRole, roles, permissions } = usePermissions();
return (
<div>
{can("users.create") && <CreateButton />}
{canAny(["posts.edit", "posts.create"]) && <PostsToolbar />}
{canAll(["orders.view", "orders.export"]) && <ExportButton />}
{hasRole("admin") && <AdminSettings />}
</div>
);
}| Method | Description |
| ----------------------- | --------------------------------- |
| can(permission) | User has this specific permission |
| canAny(permissions[]) | User has at least one of these |
| canAll(permissions[]) | User has all of these |
| hasRole(role) | User has this role |
| roles | string[] — all user roles |
| permissions | string[] — all user permissions |
<Can> guard component
import { Can } from "@specscreen/backoffice-core";
// Hide when no permission (default)
<Can permission="users.delete">
<DeleteButton />
</Can>
// Show fallback instead of hiding
<Can permission="users.create" fallback={<CreateButton disabled />}>
<CreateButton />
</Can>
// Role-based
<Can role="admin">
<AdminPanel />
</Can>
// Require all permissions
<Can permissions={["orders.view", "orders.export"]}>
<ExportButton />
</Can>Resource-level RBAC
Protect entire resources (pages + sidebar items) via meta:
{
name: "billing",
label: "Billing",
path: "/billing",
meta: {
requiredRoles: ["admin"], // must have at least one
requiredPermissions: ["billing.read"], // must have all
hideIfUnauthorized: true, // hide from sidebar (default: true)
}
}- When
hideIfUnauthorized: true(default) — the item disappears from the sidebar - Navigating directly to the path renders
<AccessDenied />
Components
<BackofficeApp>
The root component. Wraps everything with auth context, guards, and layout.
<BackofficeApp
config={config}
authProvider={authProvider}
dataProvider={dataProvider} // optional
NavLink={NavLink} // optional, pass your router's Link
currentPath={pathname} // optional, for active sidebar item
loginPageProps={{ logo: "/logo.svg", appName: "My App" }}
headerActionLabel="New Record" // fallback — used when no page overrides via useHeaderActions
onHeaderAction={() => router.push("/new")}
onLoginSuccess={() => router.push("/dashboard")}
>
{children}
</BackofficeApp>useHeaderActions — page-controlled header button
Let pages declare their own header action without wiring everything through the config. The button appears while the page is mounted and clears automatically on unmount.
import { SPC_useHeaderActions } from "@specscreen/backoffice-core";
function UsersPage() {
const [open, setOpen] = useState(false);
SPC_useHeaderActions({
label: "Add Team Member",
onClick: () => setOpen(true),
});
return (
<>
<UserTable />
{open && <AddTeamMemberModal onClose={() => setOpen(false)} />}
</>
);
}The page-level context action always takes precedence over the static headerActionLabel/onHeaderAction props on <BackofficeApp>. Those props remain as a global fallback.
<AppShell>
The layout shell used internally. Use it directly only when you need full control.
<AppShell
config={config}
NavLink={NavLink}
currentPath={pathname}
pageTitle="Users"
headerActionLabel="Invite"
onHeaderAction={handleInvite}
>
<UsersPage />
</AppShell><AuthGuard>
Redirect unauthenticated users to the login page:
<AuthGuard renderLogin={() => <MyCustomLogin />}>
<ProtectedPage />
</AuthGuard><ResourceGuard>
Protect a page by resource meta:
<ResourceGuard meta={{ requiredPermissions: ["users.read"] }}>
<UsersPage />
</ResourceGuard><AccessDenied>
Default access-denied page. Shown automatically by <ResourceGuard>.
<AccessDenied
message="You need the 'admin' role to access this page."
redirectPath="/dashboard"
redirectLabel="Go to Dashboard"
/><LoadingScreen>
Shown during the auth boot phase. Override via renderLoading:
<BackofficeApp renderLoading={() => <MySpinner />} ... />Custom login page
<BackofficeApp
renderLogin={() => <MyCustomLoginPage />}
...
/>Inside MyCustomLoginPage, use useAuth() to call login():
function MyCustomLoginPage() {
const { login } = useAuth();
const handleSubmit = async (email: string, password: string) => {
await login(email, password);
};
// ...
}Type reference
User
interface User {
id: string;
email: string;
name?: string;
roles?: string[];
permissions?: string[];
}Resource
interface Resource {
name: string;
label: string;
path: string;
icon?: ComponentType<{ className?: string }>;
list?: ComponentType;
create?: ComponentType;
edit?: ComponentType;
show?: ComponentType;
meta?: ResourceMeta;
}ResourceMeta
interface ResourceMeta {
requiredRoles?: string[]; // at least one role must match
requiredPermissions?: string[]; // all permissions must match
hideIfUnauthorized?: boolean; // default true
}BackofficeConfig
interface BackofficeConfig {
appName: string;
logo?: ComponentType<{ className?: string }> | string;
resources?: Resource[];
sidebarGroups?: SidebarGroup[];
sidebarFooterLinks?: Array<{
label: string;
/** Navigate to this path. Omit when using `onClick`. */
path?: string;
icon?: ComponentType;
/** Open a modal or trigger an action instead of navigating. */
onClick?: () => void;
}>;
loginRedirect?: string;
logoutRedirect?: string;
}Security note
The RBAC system in this framework is purely for UI/UX. It hides and shows elements; it does not enforce security. Your backend API must validate every request independently.
Exports
// Root
export { BackofficeApp } from "@specscreen/backoffice-core";
// Hooks
export {
useAuth,
usePermissions,
useHeaderActions,
} from "@specscreen/backoffice-core";
// Guards
export { AuthGuard, ResourceGuard, Can } from "@specscreen/backoffice-core";
// Layout
export { AppShell, Sidebar, SidebarToggle } from "@specscreen/backoffice-core";
// Feedback
export {
LoginPage,
AccessDenied,
LoadingScreen,
} from "@specscreen/backoffice-core";
// RBAC utilities (pure functions, no React)
export {
canAccessResource,
evaluateCan,
evaluateHasRole,
evaluateCanAny,
evaluateCanAll,
} from "@specscreen/backoffice-core";
// Types
export type {
User,
AuthProvider,
AuthState,
Resource,
ResourceMeta,
SidebarGroup,
BackofficeConfig,
DataProvider,
HeaderAction,
} from "@specscreen/backoffice-core";
// Styles
import "@specscreen/backoffice-core/styles";