create-nevela 0.4.2 → 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 (41) hide show
  1. package/README.md +2 -0
  2. package/index.mjs +92 -22
  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
  41. package/tool.mjs +45 -20
@@ -0,0 +1,117 @@
1
+ "use client";
2
+
3
+ import Link from "next/link";
4
+ import { useRouter } from "next/navigation";
5
+ import { useState, useTransition } from "react";
6
+ import { LogOutIcon, MoreHorizontalIcon, PencilIcon, PowerIcon, PowerOffIcon, Trash2Icon } from "lucide-react";
7
+ import { toast } from "sonner";
8
+ import { deleteUserAction, signOutUserAction, updateUserAction } from "@/app/dashboard/access/actions";
9
+ import {
10
+ AlertDialog,
11
+ AlertDialogCancel,
12
+ AlertDialogContent,
13
+ AlertDialogDescription,
14
+ AlertDialogFooter,
15
+ AlertDialogHeader,
16
+ AlertDialogTitle,
17
+ } from "@/components/ui/alert-dialog";
18
+ import { Button } from "@/components/ui/button";
19
+ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
20
+ import { Spinner } from "@/components/ui/spinner";
21
+ import type { ManagedUser } from "@/lib/access";
22
+
23
+ /**
24
+ * What can be done to one user from the list. Laravel has the last word on each: it
25
+ * refuses, with its reason, to switch off the last administrator and the like.
26
+ */
27
+ export function UserRowActions({ user, canEdit, canDelete }: { user: ManagedUser; canEdit: boolean; canDelete: boolean }) {
28
+ const router = useRouter();
29
+ const [confirming, setConfirming] = useState(false);
30
+ const [pending, startTransition] = useTransition();
31
+ const who = user.name || user.email;
32
+
33
+ function run(work: () => Promise<{ ok: true } | { ok: false; error: string }>, done: string) {
34
+ startTransition(async () => {
35
+ const result = await work();
36
+ if (result.ok) {
37
+ toast.success(done);
38
+ setConfirming(false);
39
+ router.refresh();
40
+ } else {
41
+ toast.error(result.error);
42
+ }
43
+ });
44
+ }
45
+
46
+ if (!canEdit && !canDelete) return null;
47
+
48
+ return (
49
+ <>
50
+ <DropdownMenu>
51
+ <DropdownMenuTrigger asChild>
52
+ <Button variant="ghost" size="icon" aria-label={`Actions for ${who}`} disabled={pending}>
53
+ {pending ? <Spinner /> : <MoreHorizontalIcon />}
54
+ </Button>
55
+ </DropdownMenuTrigger>
56
+ <DropdownMenuContent align="end">
57
+ {canEdit && (
58
+ <>
59
+ <DropdownMenuItem asChild>
60
+ <Link href={`/dashboard/access/users/${encodeURIComponent(user.id)}`}>
61
+ <PencilIcon />
62
+ Edit
63
+ </Link>
64
+ </DropdownMenuItem>
65
+ {!user.isSelf &&
66
+ (user.active ? (
67
+ <DropdownMenuItem onSelect={() => run(() => updateUserAction(user.id, { active: false }), `${who} is switched off and signed out.`)}>
68
+ <PowerOffIcon />
69
+ Switch off
70
+ </DropdownMenuItem>
71
+ ) : (
72
+ <DropdownMenuItem onSelect={() => run(() => updateUserAction(user.id, { active: true }), `${who} can sign in again.`)}>
73
+ <PowerIcon />
74
+ Switch on
75
+ </DropdownMenuItem>
76
+ ))}
77
+ {user.devices > 0 && (
78
+ <DropdownMenuItem onSelect={() => run(() => signOutUserAction(user.id), user.isSelf ? "Your other devices are signed out." : `${who} is signed out everywhere.`)}>
79
+ <LogOutIcon />
80
+ {user.isSelf ? "Sign out my other devices" : "Sign out everywhere"}
81
+ </DropdownMenuItem>
82
+ )}
83
+ </>
84
+ )}
85
+ {canDelete && !user.isSelf && (
86
+ <>
87
+ {canEdit && <DropdownMenuSeparator />}
88
+ <DropdownMenuItem variant="destructive" onSelect={() => setConfirming(true)}>
89
+ <Trash2Icon />
90
+ Delete
91
+ </DropdownMenuItem>
92
+ </>
93
+ )}
94
+ </DropdownMenuContent>
95
+ </DropdownMenu>
96
+
97
+ <AlertDialog open={confirming} onOpenChange={setConfirming}>
98
+ <AlertDialogContent>
99
+ <AlertDialogHeader>
100
+ <AlertDialogTitle>Delete {who}?</AlertDialogTitle>
101
+ <AlertDialogDescription>
102
+ Their account, their roles and their sign-in methods are removed, and they are signed out everywhere. This can&apos;t be undone. To keep the
103
+ account and stop them signing in, switch it off instead.
104
+ </AlertDialogDescription>
105
+ </AlertDialogHeader>
106
+ <AlertDialogFooter>
107
+ <AlertDialogCancel disabled={pending}>Cancel</AlertDialogCancel>
108
+ <Button variant="destructive" disabled={pending} onClick={() => run(() => deleteUserAction(user.id), `${who} is deleted.`)}>
109
+ {pending && <Spinner />}
110
+ Delete
111
+ </Button>
112
+ </AlertDialogFooter>
113
+ </AlertDialogContent>
114
+ </AlertDialog>
115
+ </>
116
+ );
117
+ }
@@ -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.2",
3
+ "version": "0.5.0",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "scripts": {
package/tool.mjs CHANGED
@@ -6,43 +6,64 @@
6
6
  // nevela upgrade the app you are in: its Nevela package and its dashboard
7
7
  //
8
8
  // Updating the command means asking the package manager that installed it to install the
9
- // latest. Which one that was is read from where this file is running from.
9
+ // newest version. Which one that was is read from where this file is running from.
10
10
 
11
11
  import fs from 'node:fs';
12
12
 
13
- /** The command that installs the latest create-nevela globally, for each package manager. */
13
+ /**
14
+ * The command that installs create-nevela globally, for each package manager.
15
+ *
16
+ * `nevela update` names the exact version it found on npm, not "latest": pnpm holds a
17
+ * version back for a day after it is published, so `pnpm add -g create-nevela@latest`
18
+ * installs yesterday's. Asked for a version by number, it installs that one.
19
+ */
14
20
  const INSTALL = {
15
- npm: ['npm', 'install', '-g', 'create-nevela@latest'],
16
- pnpm: ['pnpm', 'add', '-g', 'create-nevela@latest'],
17
- yarn: ['yarn', 'global', 'add', 'create-nevela@latest'],
18
- bun: ['bun', 'add', '-g', 'create-nevela@latest'],
21
+ npm: (version) => ['npm', 'install', '-g', `create-nevela@${version}`],
22
+ pnpm: (version) => ['pnpm', 'add', '-g', `create-nevela@${version}`],
23
+ yarn: (version) => ['yarn', 'global', 'add', `create-nevela@${version}`],
24
+ bun: (version) => ['bun', 'add', '-g', `create-nevela@${version}`],
19
25
  };
20
26
 
21
27
  /**
22
- * How the running copy was installed, from the folder it is in.
28
+ * How the running copy was installed, from the folder it is in and the folder it was
29
+ * started through.
30
+ *
31
+ * Those two differ under pnpm 11, which keeps the files of every package in its store and
32
+ * leaves a link to them in its global folder. Node reports the store; the command was
33
+ * started through the global folder. Going by the store alone, a pnpm install looked like
34
+ * an npm one, and `nevela update` updated a copy that was not the one running.
23
35
  *
24
- * Returns the package manager and the command that updates it, or null when this copy
36
+ * Returns the package manager and `command(version)`, which updates it, or null when this copy
25
37
  * isn't the installed command: it was fetched for a single run (npx, pnpm dlx, a
26
38
  * self-update), it is the repository itself, or it is a dependency in some project's own
27
39
  * node_modules. Updating the global command would not change the copy that is running in
28
40
  * any of those cases.
29
41
  *
30
- * @param {string} dir The folder this package is running from
42
+ * @param {string} dir The folder this package's files are in
31
43
  * @param {(file: string) => boolean} exists Whether a file exists; replaceable in tests
44
+ * @param {string} startedFrom The folder it was started through, when that is another
32
45
  */
33
- export function globalInstall(dir, exists = fs.existsSync) {
34
- const plain = dir.replace(/\\/g, '/').replace(/\/+$/, '');
46
+ export function globalInstall(dir, exists = fs.existsSync, startedFrom = dir) {
47
+ const tidy = (folder) => folder.replace(/\\/g, '/').replace(/\/+$/, '');
48
+ const plain = tidy(dir);
35
49
  const where = plain.toLowerCase();
50
+ const started = tidy(startedFrom).toLowerCase();
51
+ const found = (manager) => ({ manager, command: (version = 'latest') => INSTALL[manager](version) });
36
52
 
37
53
  // Fetched for one run and thrown away afterwards.
38
- if (/\/_npx\/|\/dlx[-/]|\/\.?bunx-|\/xfs-[0-9a-f]+\/|\/create-nevela-\d[^/]*\/package$/.test(where)) return null;
54
+ if ([where, started].some((folder) => /\/_npx\/|\/dlx[-/]|\/\.?bunx-|\/xfs-[0-9a-f]+\/|\/create-nevela-\d[^/]*\/package$/.test(folder))) return null;
39
55
  // Installed, as opposed to checked out: it sits in somebody's node_modules.
40
56
  if (!/\/node_modules\/create-nevela$/.test(where)) return null;
41
57
 
42
58
  // pnpm, yarn and bun each keep global packages in a folder of their own.
43
- if (/\/pnpm\/global\//.test(where)) return { manager: 'pnpm', command: INSTALL.pnpm };
44
- if (/\/\.bun\/install\/global\//.test(where)) return { manager: 'bun', command: INSTALL.bun };
45
- if (/\/yarn\/(data\/)?global\/|\/\.config\/yarn\/global\//.test(where)) return { manager: 'yarn', command: INSTALL.yarn };
59
+ for (const folder of [started, where]) {
60
+ if (!/\/node_modules\/create-nevela$/.test(folder)) continue;
61
+ if (/\/pnpm\/global\/|\/global\/v?\d+\/([^/]+\/)?node_modules\/create-nevela$/.test(folder)) return found('pnpm');
62
+ if (/\/\.bun\/install\/global\//.test(folder)) return found('bun');
63
+ if (/\/yarn\/(data\/)?global\/|\/\.config\/yarn\/global\//.test(folder)) return found('yarn');
64
+ }
65
+ // In pnpm's store, and not reached through its global folder: a project's dependency.
66
+ if (/\/store\/v?\d+\/links\//.test(where)) return null;
46
67
 
47
68
  // npm's global folder is a plain node_modules, like a project's. Two things tell them
48
69
  // apart: a project has a package.json beside its node_modules, and a dependency of a
@@ -51,7 +72,7 @@ export function globalInstall(dir, exists = fs.existsSync) {
51
72
  const owner = plain.slice(0, where.lastIndexOf('/node_modules/'));
52
73
  if (exists(`${owner}/package.json`)) return null;
53
74
 
54
- return { manager: 'npm', command: INSTALL.npm };
75
+ return found('npm');
55
76
  }
56
77
 
57
78
  /**
@@ -90,14 +111,18 @@ export function copiesOnPath(env = process.env, { exists = fs.existsSync, real =
90
111
  return found;
91
112
  }
92
113
 
93
- /** What removes the copy whose command is in `dir`, judged by the folder. */
94
- export function removeCommand(dir) {
114
+ /**
115
+ * What removes the copy whose command is in `dir`, judged by the folder and by the
116
+ * launcher in it: a small script that names the folder the package itself is in.
117
+ */
118
+ export function removeCommand(dir, launcher = '') {
95
119
  const where = dir.replace(/\\/g, '/').toLowerCase();
96
- if (/\/pnpm(\/|$)/.test(where)) return 'pnpm remove -g create-nevela';
120
+ const points = launcher.replace(/\\/g, '/').toLowerCase();
121
+ if (/\/pnpm(\/|$)/.test(where) || /\/global\/v?\d+\/([^/]+\/)?node_modules\/create-nevela\//.test(points)) return 'pnpm remove -g create-nevela';
97
122
  if (/\/\.bun\//.test(where)) return 'bun remove -g create-nevela';
98
123
  if (/\/yarn\/|\/\.yarn\//.test(where)) return 'yarn global remove create-nevela';
99
124
  return 'npm uninstall -g create-nevela';
100
125
  }
101
126
 
102
127
  /** What to type to get the `nevela` command, for someone who doesn't have it yet. */
103
- export const INSTALL_COMMAND = INSTALL.npm.join(' ').replace('@latest', '');
128
+ export const INSTALL_COMMAND = 'npm install -g create-nevela';