create-nevela 0.4.3 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +2 -0
  2. package/index.mjs +24 -10
  3. package/package.json +1 -1
  4. package/template/laravel/config/nevela.php +17 -2
  5. package/template/laravel/database/migrations/2026_10_06_000000_create_nevela_access_tables.php +79 -0
  6. package/template/laravel/src/Access/Access.php +168 -0
  7. package/template/laravel/src/Access/Permissions.php +271 -0
  8. package/template/laravel/src/Access/SampleUsers.php +81 -0
  9. package/template/laravel/src/Auth/Account.php +17 -1
  10. package/template/laravel/src/Console/SetupCommand.php +11 -2
  11. package/template/laravel/src/Console/UserCommand.php +151 -59
  12. package/template/laravel/src/Generator/ResourceGenerator.php +8 -7
  13. package/template/laravel/src/Http/Access/RolesAreSetUp.php +24 -0
  14. package/template/laravel/src/Http/Access/RolesController.php +197 -0
  15. package/template/laravel/src/Http/Access/UsersController.php +382 -0
  16. package/template/laravel/src/Http/Auth/AccountController.php +5 -1
  17. package/template/laravel/src/Models/Role.php +73 -0
  18. package/template/laravel/src/Nevela.php +2 -1
  19. package/template/laravel/src/NevelaServiceProvider.php +29 -0
  20. package/template/web/app/api/[...path]/route.ts +5 -1
  21. package/template/web/app/dashboard/access/actions.ts +70 -0
  22. package/template/web/app/dashboard/access/roles/[id]/page.tsx +32 -0
  23. package/template/web/app/dashboard/access/roles/new/page.tsx +18 -0
  24. package/template/web/app/dashboard/access/roles/page.tsx +77 -0
  25. package/template/web/app/dashboard/access/users/[id]/page.tsx +28 -0
  26. package/template/web/app/dashboard/access/users/new/page.tsx +17 -0
  27. package/template/web/app/dashboard/access/users/page.tsx +158 -0
  28. package/template/web/app/dashboard/actions.ts +10 -9
  29. package/template/web/app/dashboard/import-actions.ts +6 -5
  30. package/template/web/app/dashboard/layout.tsx +5 -2
  31. package/template/web/app/dashboard/page.tsx +27 -4
  32. package/template/web/components/access/role-form.tsx +389 -0
  33. package/template/web/components/access/user-form.tsx +167 -0
  34. package/template/web/components/access/user-row-actions.tsx +117 -0
  35. package/template/web/components/access/users-toolbar.tsx +82 -0
  36. package/template/web/components/dashboard/dashboard-sidebar.tsx +10 -2
  37. package/template/web/lib/access.ts +107 -0
  38. package/template/web/lib/dashboard.ts +51 -18
  39. package/template/web/lib/session.ts +11 -0
  40. package/template/web/package.json +1 -1
@@ -0,0 +1,82 @@
1
+ "use client";
2
+
3
+ import { usePathname, useRouter, useSearchParams } from "next/navigation";
4
+ import { useEffect, useRef, useState } from "react";
5
+ import { SearchIcon } from "lucide-react";
6
+ import { Input } from "@/components/ui/input";
7
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
8
+
9
+ const ANY = "any";
10
+
11
+ /**
12
+ * Search and filters for the list of users. Each one is a query parameter, so a filtered
13
+ * list has an address of its own and survives a reload.
14
+ */
15
+ export function UsersToolbar({ roles }: { roles: { id: string; name: string }[] }) {
16
+ const router = useRouter();
17
+ const pathname = usePathname();
18
+ const params = useSearchParams();
19
+ const [search, setSearch] = useState(params.get("q") ?? "");
20
+ const typed = useRef(false);
21
+
22
+ function go(changes: Record<string, string | null>) {
23
+ const next = new URLSearchParams(params);
24
+ for (const [key, value] of Object.entries(changes)) {
25
+ if (value) next.set(key, value);
26
+ else next.delete(key);
27
+ }
28
+ // Any change of filter starts again from the first page.
29
+ next.delete("page");
30
+ router.replace(next.size > 0 ? `${pathname}?${next}` : pathname);
31
+ }
32
+
33
+ // Search as they type, a moment after they stop.
34
+ useEffect(() => {
35
+ if (!typed.current) return;
36
+ const timer = setTimeout(() => go({ q: search.trim() || null }), 300);
37
+ return () => clearTimeout(timer);
38
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- only the text starts a search
39
+ }, [search]);
40
+
41
+ return (
42
+ <div className="flex flex-col gap-2 sm:flex-row sm:items-center">
43
+ <div className="relative sm:max-w-xs sm:flex-1">
44
+ <SearchIcon className="pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground" />
45
+ <Input
46
+ type="search"
47
+ aria-label="Search users"
48
+ placeholder="Search by name or email"
49
+ className="pl-8"
50
+ value={search}
51
+ onChange={(event) => {
52
+ typed.current = true;
53
+ setSearch(event.target.value);
54
+ }}
55
+ />
56
+ </div>
57
+ <Select value={params.get("role") ?? ANY} onValueChange={(value) => go({ role: value === ANY ? null : value })}>
58
+ <SelectTrigger className="sm:w-44" aria-label="Role">
59
+ <SelectValue />
60
+ </SelectTrigger>
61
+ <SelectContent>
62
+ <SelectItem value={ANY}>Every role</SelectItem>
63
+ {roles.map((role) => (
64
+ <SelectItem key={role.id} value={role.id}>
65
+ {role.name}
66
+ </SelectItem>
67
+ ))}
68
+ </SelectContent>
69
+ </Select>
70
+ <Select value={params.get("status") ?? ANY} onValueChange={(value) => go({ status: value === ANY ? null : value })}>
71
+ <SelectTrigger className="sm:w-40" aria-label="Status">
72
+ <SelectValue />
73
+ </SelectTrigger>
74
+ <SelectContent>
75
+ <SelectItem value={ANY}>Any status</SelectItem>
76
+ <SelectItem value="active">Active</SelectItem>
77
+ <SelectItem value="inactive">Switched off</SelectItem>
78
+ </SelectContent>
79
+ </Select>
80
+ </div>
81
+ );
82
+ }
@@ -2,7 +2,7 @@
2
2
 
3
3
  import Link from "next/link";
4
4
  import { usePathname, useSearchParams } from "next/navigation";
5
- import { ActivityIcon, LayoutDashboardIcon, UserCircleIcon } from "lucide-react";
5
+ import { ActivityIcon, LayoutDashboardIcon, ShieldCheckIcon, UserCircleIcon, UsersIcon } from "lucide-react";
6
6
  import { ChevronsUpDownIcon } from "lucide-react";
7
7
  import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
8
8
  import {
@@ -63,6 +63,7 @@ export function DashboardSidebar({
63
63
  resources,
64
64
  links = [],
65
65
  isAdmin = false,
66
+ access = { users: false, roles: false },
66
67
  user,
67
68
  }: {
68
69
  appName: string;
@@ -71,12 +72,19 @@ export function DashboardSidebar({
71
72
  links?: Array<{ label: string; href: string; icon: string }>;
72
73
  /** Whether to show the sections that manage the app itself. */
73
74
  isAdmin?: boolean;
75
+ /** Which of the screens for managing people this person may open. */
76
+ access?: { users: boolean; roles: boolean };
74
77
  user: DashboardUser;
75
78
  }) {
76
79
  const pathname = usePathname();
77
80
  // A saved view is the query it was saved with, so the one you're on is the one that matches.
78
81
  const search = useSearchParams().toString();
79
- const manage = [...ACCOUNT_LINKS, ...(isAdmin ? ADMIN_LINKS : [])];
82
+ const manage = [
83
+ ...ACCOUNT_LINKS,
84
+ ...(access.users ? [{ href: "/dashboard/access/users", label: "Users", icon: UsersIcon }] : []),
85
+ ...(access.roles ? [{ href: "/dashboard/access/roles", label: "Roles", icon: ShieldCheckIcon }] : []),
86
+ ...(isAdmin ? ADMIN_LINKS : []),
87
+ ];
80
88
 
81
89
  return (
82
90
  <Sidebar collapsible="icon">
@@ -0,0 +1,107 @@
1
+ import { laravel } from "./laravel";
2
+
3
+ /**
4
+ * Users, roles and permissions, as Laravel serves them (`/_nevela/users`, `/_nevela/roles`,
5
+ * `/_nevela/permissions`). Laravel decides who may see and change them; these are the
6
+ * shapes, and the calls the Users and Roles screens read with.
7
+ */
8
+
9
+ export interface ManagedUser {
10
+ id: string;
11
+ name: string | null;
12
+ email: string;
13
+ /** False when the account is switched off: it can't sign in. */
14
+ active: boolean;
15
+ emailVerified: boolean;
16
+ twoFactorEnabled: boolean;
17
+ /** The small rendition of their picture, ready for an <img>, or null. */
18
+ image: string | null;
19
+ roles: { id: string; name: string }[];
20
+ /** Holds a role that allows everything. */
21
+ isAdmin: boolean;
22
+ /** This is the person looking. */
23
+ isSelf: boolean;
24
+ /** Whether the person looking may change this account: it may do no more than they may. */
25
+ withinYours: boolean;
26
+ /** How many devices they are signed in on. */
27
+ devices: number;
28
+ lastActiveAt: string | null;
29
+ createdAt: string | null;
30
+ }
31
+
32
+ export interface ManagedRole {
33
+ id: string;
34
+ name: string;
35
+ description: string | null;
36
+ /** As written: permissions, and patterns such as "products.*" or "@resources.view". */
37
+ grants: string[];
38
+ /** What the grants come to today. */
39
+ permissions: string[];
40
+ /** Built in: it can be edited, and not renamed or deleted. */
41
+ isSystem: boolean;
42
+ /** Allows everything. */
43
+ isAdmin: boolean;
44
+ /** How many people hold it. */
45
+ users: number;
46
+ /** Whether the person looking may hand it out or change it: it allows nothing they don't hold. */
47
+ withinYours: boolean;
48
+ }
49
+
50
+ export interface PermissionFeature {
51
+ /** The first half of a permission: "products". */
52
+ key: string;
53
+ name: string;
54
+ /** The second halves that mean something for it: create, view, edit, delete. */
55
+ actions: string[];
56
+ }
57
+
58
+ export interface PermissionModule {
59
+ key: string;
60
+ name: string;
61
+ groups: { key: string; name: string; features: PermissionFeature[] }[];
62
+ }
63
+
64
+ export interface PermissionCatalog {
65
+ modules: PermissionModule[];
66
+ /** How many permissions there are in all. */
67
+ total: number;
68
+ }
69
+
70
+ export interface UserList {
71
+ data: ManagedUser[];
72
+ meta: { page: number; perPage: number; total: number; totalPages: number };
73
+ }
74
+
75
+ /** What a call answered with when it didn't work, in words for the person. */
76
+ export function accessError(response: { status: number; body: unknown }): string {
77
+ const body = (response.body ?? {}) as { error?: string; issues?: { message: string }[] };
78
+ if (body.issues?.length) return body.issues.map((issue) => issue.message).join(" ");
79
+ return body.error ?? `Something went wrong (${response.status}).`;
80
+ }
81
+
82
+ export async function listUsers(params: { q?: string; role?: string; status?: string; page?: string }): Promise<UserList | null> {
83
+ const query = new URLSearchParams();
84
+ for (const [key, value] of Object.entries(params)) if (value) query.set(key, value);
85
+ const response = await laravel(`_nevela/users?${query}`);
86
+ return response.ok ? (response.body as UserList) : null;
87
+ }
88
+
89
+ export async function getUser(id: string): Promise<ManagedUser | null> {
90
+ const response = await laravel(`_nevela/users/${encodeURIComponent(id)}`);
91
+ return response.ok ? (response.body as ManagedUser) : null;
92
+ }
93
+
94
+ export async function listRoles(): Promise<ManagedRole[]> {
95
+ const response = await laravel("_nevela/roles");
96
+ return response.ok ? (response.body as { data: ManagedRole[] }).data : [];
97
+ }
98
+
99
+ export async function getRole(id: string): Promise<ManagedRole | null> {
100
+ const response = await laravel(`_nevela/roles/${encodeURIComponent(id)}`);
101
+ return response.ok ? (response.body as ManagedRole) : null;
102
+ }
103
+
104
+ export async function getCatalog(): Promise<PermissionCatalog> {
105
+ const response = await laravel("_nevela/permissions");
106
+ return response.ok ? (response.body as PermissionCatalog) : { modules: [], total: 0 };
107
+ }
@@ -1,6 +1,6 @@
1
1
  import { cache } from "react";
2
2
  import { notFound, redirect } from "next/navigation";
3
- import { allowedActions, can, type Policy, type PolicyAction, type Resource } from "@flaredev/core";
3
+ import { can, type Policy, type PolicyAction, type Resource } from "@flaredev/core";
4
4
  import { laravelStore, type ResourceStore } from "@/lib/resource";
5
5
  import { policies } from "@/policies";
6
6
  import { resources } from "@/resources";
@@ -16,23 +16,55 @@ import { getSession } from "./session";
16
16
  * (lib/session.ts); nothing here touches a database.
17
17
  */
18
18
 
19
- export const ADMIN_ROLES = ["admin", "staff"];
20
-
21
19
  /**
22
- * A display-only policy for hiding buttons a role can't use. Optional: Laravel's
23
- * policies (app/Policies) decide what is actually allowed, and a resource without an
24
- * entry here shows every action and lets Laravel answer 403.
20
+ * A further display-only policy, by role name (policies/index.ts). Optional, and on top of
21
+ * the person's permissions: an entry here can hide more, never show more.
25
22
  */
26
23
  export function policyFor(resourceName: string): Policy | undefined {
27
24
  return (policies as Record<string, Policy | undefined>)[resourceName];
28
25
  }
29
26
 
27
+ /** What each of the dashboard's actions is called as a permission. */
28
+ const VERB: Record<PolicyAction, string> = { list: "view", read: "view", create: "create", update: "edit", delete: "delete" };
29
+
30
+ /** The permission an action on a resource asks for: "products.edit". */
31
+ export function permissionFor(resource: Resource, action: PolicyAction): string {
32
+ return `${resource.table}.${VERB[action]}`;
33
+ }
34
+
35
+ /**
36
+ * The signed-in person and what they may do.
37
+ *
38
+ * `may("products.view")` answers from the permissions Laravel worked out from their roles.
39
+ * It is for showing and hiding: Laravel decides every request again for itself, so nothing
40
+ * here is what keeps anyone out.
41
+ */
30
42
  export async function dashboardSession() {
31
43
  const session = await getSession();
32
- if (!session) return { session: null, role: null, allowed: false } as const;
33
- const role = session.user.role ?? null;
34
- const readable = allResources().some((resource) => can(policyFor(resource.name), role, "read"));
35
- return { session, role, allowed: (role !== null && ADMIN_ROLES.includes(role)) || readable } as const;
44
+ if (!session) return { session: null, role: null, roles: [] as string[], allowed: false, isAdmin: false, may: () => false } as const;
45
+ const { user } = session;
46
+ // An API from before roles sends no list. Everyone could do everything then, and still can.
47
+ const everything = user.permissions === undefined || user.isAdmin === true;
48
+ const held = new Set(user.permissions ?? []);
49
+ const may = (permission: string) => everything || held.has(permission);
50
+ // Anyone signed in has a dashboard, if only for their own account.
51
+ return { session, role: user.role ?? null, roles: user.roles ?? (user.role ? [user.role] : []), allowed: true, isAdmin: everything, may } as const;
52
+ }
53
+
54
+ /** Whether the current person may do `action` on a resource: their permissions, and the display policy if there is one. */
55
+ export async function mayDo(resource: Resource, action: PolicyAction): Promise<boolean> {
56
+ const { session, roles, may } = await dashboardSession();
57
+ if (!session || !may(permissionFor(resource, action))) return false;
58
+ const policy = policyFor(resource.name);
59
+ return !policy || roles.some((role) => can(policy, role, action)) || (roles.length === 0 && can(policy, null, action));
60
+ }
61
+
62
+ /** For a page that needs one permission: sends anyone without it back to the dashboard. */
63
+ export async function requirePermission(permission: string, returnTo = "/dashboard") {
64
+ const { session, may } = await dashboardSession();
65
+ if (!session) redirect(`/sign-in?next=${encodeURIComponent(returnTo)}`);
66
+ if (!may(permission)) redirect("/dashboard?error=forbidden");
67
+ return { session, may };
36
68
  }
37
69
 
38
70
  /** For admin pages: redirects signed-out visitors to sign-in and users without access to the home page. */
@@ -50,17 +82,18 @@ export async function requireDashboard(returnTo = "/dashboard") {
50
82
  */
51
83
  export async function requireAccess(resource: Resource, action: PolicyAction) {
52
84
  const { session, role } = await requireDashboard(resourcePath(resource));
53
- const policy = policyFor(resource.name);
54
- if (!can(policy, role, action)) {
55
- redirect(action !== "read" && can(policy, role, "read") ? resourcePath(resource) : "/dashboard?error=forbidden");
85
+ if (!(await mayDo(resource, action))) {
86
+ redirect(action !== "read" && (await mayDo(resource, "read")) ? resourcePath(resource) : "/dashboard?error=forbidden");
56
87
  }
57
88
  return { session, role };
58
89
  }
59
90
 
60
91
  /** What the current user may do with a resource, for hiding actions they can't use. */
61
- export async function adminPermissions(resourceName: string) {
62
- const { role } = await dashboardSession();
63
- return allowedActions(policyFor(resourceName), role);
92
+ export async function adminPermissions(resourceName: string): Promise<Record<Exclude<PolicyAction, "list">, boolean>> {
93
+ const resource = allResources().find((item) => item.name === resourceName);
94
+ if (!resource) return { read: false, create: false, update: false, delete: false };
95
+ const [read, create, update, remove] = await Promise.all((["read", "create", "update", "delete"] as const).map((action) => mayDo(resource, action)));
96
+ return { read: read!, create: create!, update: update!, delete: remove! };
64
97
  }
65
98
 
66
99
  const stores = new Map<string, ResourceStore>();
@@ -121,8 +154,8 @@ export function allResources(): Resource[] {
121
154
 
122
155
  /** Resources the current user may read, for the sidebar and dashboard. */
123
156
  export async function visibleResources(): Promise<Resource[]> {
124
- const { role } = await dashboardSession();
125
- return allResources().filter((resource) => can(policyFor(resource.name), role, "read"));
157
+ const visible = await Promise.all(allResources().map((resource) => mayDo(resource, "read")));
158
+ return allResources().filter((_, index) => visible[index]);
126
159
  }
127
160
 
128
161
  export function resourcePath(resource: Resource, ...parts: string[]) {
@@ -6,7 +6,18 @@ export interface SessionUser {
6
6
  id: string;
7
7
  name: string | null;
8
8
  email: string;
9
+ /** The first of their roles, from when an account had one. */
9
10
  role: string | null;
11
+ /** The names of their roles. */
12
+ roles?: string[];
13
+ /**
14
+ * Everything they may do, as "feature.action" ("products.view"). Laravel works it out
15
+ * from their roles; lib/dashboard.ts uses it to hide what they can't use. Missing when
16
+ * the API is from before roles, when everyone could do everything.
17
+ */
18
+ permissions?: string[];
19
+ /** Whether their roles come to everything. */
20
+ isAdmin?: boolean;
10
21
  emailVerified: boolean;
11
22
  twoFactorEnabled: boolean;
12
23
  /** The key of the profile picture (an optimised upload), or null. Show it with fileUrl(). */
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nevela-web",
3
- "version": "0.4.3",
3
+ "version": "0.5.0",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "scripts": {