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,73 @@
1
+ <?php
2
+
3
+ namespace Nevela\Laravel\Models;
4
+
5
+ use Illuminate\Database\Eloquent\Concerns\HasUuids;
6
+ use Illuminate\Database\Eloquent\Model;
7
+
8
+ /**
9
+ * A named set of grants. See Nevela\Laravel\Access\Permissions for what a grant is.
10
+ *
11
+ * Grants are kept as written, patterns included: a role holding "products.*" goes on
12
+ * covering products when an action is added, which a list of the permissions it came to
13
+ * on the day it was saved would not.
14
+ *
15
+ * @property string $id
16
+ * @property string $name
17
+ * @property string|null $description
18
+ * @property list<string> $grants
19
+ * @property bool $is_system
20
+ */
21
+ class Role extends Model
22
+ {
23
+ use HasUuids;
24
+
25
+ /** The three every app starts with. They can be edited, and not renamed or deleted. */
26
+ public const ADMIN = 'ADMIN';
27
+
28
+ public const EDITOR = 'EDITOR';
29
+
30
+ public const USER = 'USER';
31
+
32
+ protected $table = 'nevela_roles';
33
+
34
+ protected $guarded = [];
35
+
36
+ protected function casts(): array
37
+ {
38
+ return ['is_system' => 'boolean'];
39
+ }
40
+
41
+ /**
42
+ * The grants, as a list. A value that can't be read comes to no grants at all: this
43
+ * decides who may do what, and the safe way to fail is to refuse.
44
+ *
45
+ * @return list<string>
46
+ */
47
+ public function getGrantsAttribute(mixed $value): array
48
+ {
49
+ $decoded = is_string($value) ? json_decode($value, true) : $value;
50
+
51
+ return is_array($decoded) ? array_values(array_filter($decoded, 'is_string')) : [];
52
+ }
53
+
54
+ /** @param list<string>|null $value */
55
+ public function setGrantsAttribute(mixed $value): void
56
+ {
57
+ $this->attributes['grants'] = json_encode(array_values(array_unique(array_filter((array) $value, 'is_string'))));
58
+ }
59
+
60
+ /**
61
+ * The roles a new app is given.
62
+ *
63
+ * @return list<array{name: string, description: string, grants: list<string>}>
64
+ */
65
+ public static function defaults(): array
66
+ {
67
+ return [
68
+ ['name' => self::ADMIN, 'description' => 'Full access to every feature and action.', 'grants' => ['*']],
69
+ ['name' => self::EDITOR, 'description' => 'Works with the app\'s records. Sees who the users are, and manages neither users nor roles.', 'grants' => ['@resources.*', 'users.view']],
70
+ ['name' => self::USER, 'description' => 'A standard account. Signs in and manages its own profile, and nothing else.', 'grants' => []],
71
+ ];
72
+ }
73
+ }
@@ -18,7 +18,7 @@ use Nevela\Laravel\Support\Naming;
18
18
  final class Nevela
19
19
  {
20
20
  /** Written by `pnpm release`; the same number as the repository's git tag. */
21
- public const VERSION = '0.4.2';
21
+ public const VERSION = '0.5.0';
22
22
 
23
23
  /** @var array<string, Descriptor> */
24
24
  private static array $descriptors = [];
@@ -63,6 +63,7 @@ final class Nevela
63
63
  public static function forget(): void
64
64
  {
65
65
  self::$descriptors = [];
66
+ Access\Permissions::forget();
66
67
  }
67
68
 
68
69
  /**
@@ -6,9 +6,12 @@ use Illuminate\Contracts\Debug\ExceptionHandler;
6
6
  use Illuminate\Cache\RateLimiting\Limit;
7
7
  use Illuminate\Foundation\Console\ServeCommand;
8
8
  use Illuminate\Http\Request;
9
+ use Illuminate\Support\Facades\Gate;
9
10
  use Illuminate\Support\Facades\RateLimiter;
10
11
  use Illuminate\Support\Facades\Route;
11
12
  use Illuminate\Support\ServiceProvider;
13
+ use Nevela\Laravel\Access\Access;
14
+ use Nevela\Laravel\Access\Permissions;
12
15
  use Nevela\Laravel\Console\DevCommand;
13
16
  use Nevela\Laravel\Console\GenerateCommand;
14
17
  use Nevela\Laravel\Console\MakeResourceCommand;
@@ -18,6 +21,9 @@ use Nevela\Laravel\Console\StatusCommand;
18
21
  use Nevela\Laravel\Console\UpdateCommand;
19
22
  use Nevela\Laravel\Console\UserCommand;
20
23
  use Nevela\Laravel\Console\VersionCommand;
24
+ use Nevela\Laravel\Http\Access\RolesAreSetUp;
25
+ use Nevela\Laravel\Http\Access\RolesController;
26
+ use Nevela\Laravel\Http\Access\UsersController;
21
27
  use Nevela\Laravel\Http\FlareErrors;
22
28
  use Nevela\Laravel\Http\Auth\AccountController;
23
29
  use Nevela\Laravel\Http\Auth\SecurityController;
@@ -59,6 +65,12 @@ final class NevelaServiceProvider extends ServiceProvider
59
65
  }
60
66
  });
61
67
 
68
+ // An ability shaped like a permission ("products.view") is answered from the
69
+ // person's roles. So `$user->can('products.view')`, `Gate::authorize(...)` and
70
+ // `@can` all work, in a policy or anywhere else. Any other ability is left to
71
+ // the app's own policies and gates.
72
+ Gate::before(fn ($user, $ability) => is_string($ability) && Permissions::isPermission($ability) ? Access::allows($user, $ability) : null);
73
+
62
74
  if (! $this->app->routesAreCached()) {
63
75
  $this->registerRoutes();
64
76
  }
@@ -159,6 +171,23 @@ final class NevelaServiceProvider extends ServiceProvider
159
171
  'data' => array_map(fn ($name) => (array) Uploads::profile($name), array_keys((array) config('nevela.uploads.profiles', ['default' => []]))),
160
172
  ]))->name('profiles');
161
173
 
174
+ // Users and roles, for whoever holds the permissions to manage them.
175
+ Route::middleware(RolesAreSetUp::class)->group(function () {
176
+ Route::get('_nevela/users', [UsersController::class, 'index'])->name('users.index');
177
+ Route::post('_nevela/users', [UsersController::class, 'store'])->name('users.store');
178
+ Route::get('_nevela/users/{id}', [UsersController::class, 'show'])->name('users.show');
179
+ Route::patch('_nevela/users/{id}', [UsersController::class, 'update'])->name('users.update');
180
+ Route::delete('_nevela/users/{id}', [UsersController::class, 'destroy'])->name('users.destroy');
181
+ Route::delete('_nevela/users/{id}/sessions', [UsersController::class, 'revokeSessions'])->name('users.sessions.revoke');
182
+
183
+ Route::get('_nevela/permissions', [RolesController::class, 'catalog'])->name('permissions');
184
+ Route::get('_nevela/roles', [RolesController::class, 'index'])->name('roles.index');
185
+ Route::post('_nevela/roles', [RolesController::class, 'store'])->name('roles.store');
186
+ Route::get('_nevela/roles/{id}', [RolesController::class, 'show'])->name('roles.show');
187
+ Route::patch('_nevela/roles/{id}', [RolesController::class, 'update'])->name('roles.update');
188
+ Route::delete('_nevela/roles/{id}', [RolesController::class, 'destroy'])->name('roles.destroy');
189
+ });
190
+
162
191
  // The descriptors, so the web app (and tooling) can check it matches the API.
163
192
  Route::get('_nevela/resources', fn () => response()->json([
164
193
  'data' => array_map(fn ($d) => $d->toArray(), Nevela::all()),
@@ -64,7 +64,11 @@ export async function GET(request: NextRequest, context: Context) {
64
64
  const value = request.headers.get(name);
65
65
  if (value) conditional[name] = value;
66
66
  }
67
- return forward(url, { method: "GET", headers: { Accept: request.headers.get("accept") ?? "application/json", ...conditional } }, !isFile);
67
+ // The API's answers are JSON, whatever the browser said it would accept. Asked that way,
68
+ // a request with no session is told 401; asked as a page, Laravel would try to send it
69
+ // to a sign-in route this app doesn't have. A file is asked for as the browser wants it.
70
+ const accept = isFile ? (request.headers.get("accept") ?? "*/*") : "application/json";
71
+ return forward(url, { method: "GET", headers: { Accept: accept, ...conditional } }, !isFile);
68
72
  }
69
73
 
70
74
  export async function PUT(request: NextRequest, context: Context) {
@@ -0,0 +1,70 @@
1
+ "use server";
2
+
3
+ import { revalidatePath } from "next/cache";
4
+ import { accessError, type ManagedRole, type ManagedUser } from "@/lib/access";
5
+ import { laravel } from "@/lib/laravel";
6
+ import { getSession } from "@/lib/session";
7
+
8
+ /**
9
+ * Changes to users and roles. Each one is a call to Laravel, which checks the person's
10
+ * permissions and the rules on top of them (nobody hands out more than they hold; the
11
+ * last administrator stays), and whose answer is passed back as it is.
12
+ */
13
+
14
+ export type AccessResult<T = unknown> = { ok: true; data: T } | { ok: false; error: string; issues?: { path: string; message: string }[] };
15
+
16
+ async function send<T>(path: string, method: "POST" | "PATCH" | "DELETE", body?: unknown): Promise<AccessResult<T>> {
17
+ if (!(await getSession())) return { ok: false, error: "Sign in to continue." };
18
+ const response = await laravel(path, { method, body });
19
+ if (!response.ok) {
20
+ const issues = (response.body as { issues?: { path: string; message: string }[] } | null)?.issues;
21
+ return { ok: false, error: accessError(response), ...(issues ? { issues } : {}) };
22
+ }
23
+ revalidatePath("/dashboard/access", "layout");
24
+ return { ok: true, data: response.body as T };
25
+ }
26
+
27
+ export interface UserInput {
28
+ name?: string;
29
+ email?: string;
30
+ /** Left out, the password stays as it is. */
31
+ password?: string;
32
+ /** Role ids. */
33
+ roles?: string[];
34
+ active?: boolean;
35
+ }
36
+
37
+ export async function createUserAction(input: UserInput): Promise<AccessResult<ManagedUser>> {
38
+ return send("_nevela/users", "POST", input);
39
+ }
40
+
41
+ export async function updateUserAction(id: string, input: UserInput): Promise<AccessResult<ManagedUser>> {
42
+ return send(`_nevela/users/${encodeURIComponent(id)}`, "PATCH", input);
43
+ }
44
+
45
+ export async function deleteUserAction(id: string): Promise<AccessResult<null>> {
46
+ return send(`_nevela/users/${encodeURIComponent(id)}`, "DELETE");
47
+ }
48
+
49
+ /** Sign someone out of every device. */
50
+ export async function signOutUserAction(id: string): Promise<AccessResult<{ revoked: number }>> {
51
+ return send(`_nevela/users/${encodeURIComponent(id)}/sessions`, "DELETE");
52
+ }
53
+
54
+ export interface RoleInput {
55
+ name?: string;
56
+ description?: string | null;
57
+ grants?: string[];
58
+ }
59
+
60
+ export async function createRoleAction(input: RoleInput): Promise<AccessResult<ManagedRole>> {
61
+ return send("_nevela/roles", "POST", input);
62
+ }
63
+
64
+ export async function updateRoleAction(id: string, input: RoleInput): Promise<AccessResult<ManagedRole>> {
65
+ return send(`_nevela/roles/${encodeURIComponent(id)}`, "PATCH", input);
66
+ }
67
+
68
+ export async function deleteRoleAction(id: string): Promise<AccessResult<null>> {
69
+ return send(`_nevela/roles/${encodeURIComponent(id)}`, "DELETE");
70
+ }
@@ -0,0 +1,32 @@
1
+ import { notFound } from "next/navigation";
2
+ import { RoleForm } from "@/components/access/role-form";
3
+ import { PageHeader } from "@/components/dashboard/page-header";
4
+ import { getCatalog, getRole } from "@/lib/access";
5
+ import { requirePermission } from "@/lib/dashboard";
6
+
7
+ export const metadata = { title: "Role" };
8
+
9
+ export default async function RolePage({ params }: { params: Promise<{ id: string }> }) {
10
+ const { id } = await params;
11
+ const { session, may } = await requirePermission("roles.view", `/dashboard/access/roles/${id}`);
12
+ const [role, catalog] = await Promise.all([getRole(id), getCatalog()]);
13
+ if (!role) notFound();
14
+ const { user } = session;
15
+
16
+ return (
17
+ <>
18
+ <PageHeader
19
+ title={role.name}
20
+ description={role.users === 1 ? "One person holds this role." : `${role.users.toLocaleString()} people hold this role.`}
21
+ crumbs={[{ label: "Dashboard", href: "/dashboard" }, { label: "Roles", href: "/dashboard/access/roles" }, { label: role.name }]}
22
+ />
23
+ <RoleForm
24
+ role={role}
25
+ catalog={catalog}
26
+ mine={user.isAdmin || user.permissions === undefined ? null : user.permissions}
27
+ canEdit={may("roles.edit")}
28
+ canDelete={may("roles.delete")}
29
+ />
30
+ </>
31
+ );
32
+ }
@@ -0,0 +1,18 @@
1
+ import { RoleForm } from "@/components/access/role-form";
2
+ import { PageHeader } from "@/components/dashboard/page-header";
3
+ import { getCatalog } from "@/lib/access";
4
+ import { requirePermission } from "@/lib/dashboard";
5
+
6
+ export const metadata = { title: "New role" };
7
+
8
+ export default async function NewRolePage() {
9
+ const { session } = await requirePermission("roles.create", "/dashboard/access/roles/new");
10
+ const { user } = session;
11
+
12
+ return (
13
+ <>
14
+ <PageHeader title="New role" crumbs={[{ label: "Dashboard", href: "/dashboard" }, { label: "Roles", href: "/dashboard/access/roles" }, { label: "New" }]} />
15
+ <RoleForm catalog={await getCatalog()} mine={user.isAdmin || user.permissions === undefined ? null : user.permissions} canEdit canDelete={false} />
16
+ </>
17
+ );
18
+ }
@@ -0,0 +1,77 @@
1
+ import Link from "next/link";
2
+ import { PlusIcon, UsersIcon } from "lucide-react";
3
+ import { PageHeader } from "@/components/dashboard/page-header";
4
+ import { Badge } from "@/components/ui/badge";
5
+ import { Button } from "@/components/ui/button";
6
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
7
+ import { Progress } from "@/components/ui/progress";
8
+ import { getCatalog, listRoles } from "@/lib/access";
9
+ import { requirePermission } from "@/lib/dashboard";
10
+
11
+ export const metadata = { title: "Roles" };
12
+
13
+ /** Each role: what it allows, out of everything there is, and how many people hold it. */
14
+ export default async function RolesPage() {
15
+ const { may } = await requirePermission("roles.view", "/dashboard/access/roles");
16
+ const [roles, catalog] = await Promise.all([listRoles(), getCatalog()]);
17
+
18
+ return (
19
+ <>
20
+ <PageHeader
21
+ title="Roles"
22
+ description="A role is a set of permissions. Give someone a role on the Users screen, and they may do what it allows."
23
+ crumbs={[{ label: "Dashboard", href: "/dashboard" }, { label: "Roles" }]}
24
+ actions={
25
+ may("roles.create") && (
26
+ <Button asChild>
27
+ <Link href="/dashboard/access/roles/new">
28
+ <PlusIcon />
29
+ New role
30
+ </Link>
31
+ </Button>
32
+ )
33
+ }
34
+ />
35
+
36
+ <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
37
+ {roles.map((role) => {
38
+ const share = catalog.total > 0 ? Math.round((role.permissions.length / catalog.total) * 100) : 0;
39
+ return (
40
+ <Card key={role.id} className="flex flex-col">
41
+ <CardHeader>
42
+ <CardTitle className="flex flex-wrap items-center gap-2 text-base">
43
+ <Link href={`/dashboard/access/roles/${encodeURIComponent(role.id)}`} className="hover:underline">
44
+ {role.name}
45
+ </Link>
46
+ {role.isSystem && <Badge variant="outline">Built in</Badge>}
47
+ {role.isAdmin && <Badge>Full access</Badge>}
48
+ </CardTitle>
49
+ <CardDescription>{role.description || "No description."}</CardDescription>
50
+ </CardHeader>
51
+ <CardContent className="mt-auto flex flex-col gap-3">
52
+ <div className="flex flex-col gap-1.5">
53
+ <div className="flex items-center justify-between text-sm">
54
+ <span className="text-muted-foreground">Permissions</span>
55
+ <span className="tabular-nums">
56
+ {role.permissions.length} of {catalog.total}
57
+ </span>
58
+ </div>
59
+ <Progress value={share} aria-label={`${share}% of all permissions`} />
60
+ </div>
61
+ <div className="flex items-center justify-between">
62
+ <Link href={`/dashboard/access/users?role=${encodeURIComponent(role.id)}`} className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground">
63
+ <UsersIcon className="size-4" />
64
+ {role.users === 1 ? "1 user" : `${role.users.toLocaleString()} users`}
65
+ </Link>
66
+ <Button variant="outline" size="sm" asChild>
67
+ <Link href={`/dashboard/access/roles/${encodeURIComponent(role.id)}`}>{may("roles.edit") && role.withinYours ? "Edit" : "View"}</Link>
68
+ </Button>
69
+ </div>
70
+ </CardContent>
71
+ </Card>
72
+ );
73
+ })}
74
+ </div>
75
+ </>
76
+ );
77
+ }
@@ -0,0 +1,28 @@
1
+ import { notFound, redirect } from "next/navigation";
2
+ import { UserForm } from "@/components/access/user-form";
3
+ import { PageHeader } from "@/components/dashboard/page-header";
4
+ import { getUser, listRoles } from "@/lib/access";
5
+ import { requirePermission } from "@/lib/dashboard";
6
+
7
+ export const metadata = { title: "Edit user" };
8
+
9
+ export default async function EditUserPage({ params }: { params: Promise<{ id: string }> }) {
10
+ const { id } = await params;
11
+ await requirePermission("users.edit", `/dashboard/access/users/${id}`);
12
+ const [user, roles] = await Promise.all([getUser(id), listRoles()]);
13
+ if (!user) notFound();
14
+ // An account that may do more than you isn't yours to change, and Laravel would refuse
15
+ // every field of this form. The list doesn't link here for one; this is for the address typed in.
16
+ if (!user.withinYours && !user.isSelf) redirect("/dashboard?error=forbidden");
17
+
18
+ return (
19
+ <>
20
+ <PageHeader
21
+ title={user.name || user.email}
22
+ description={user.isSelf ? "This is your own account. Your name and picture are on your account page." : undefined}
23
+ crumbs={[{ label: "Dashboard", href: "/dashboard" }, { label: "Users", href: "/dashboard/access/users" }, { label: user.name || user.email }]}
24
+ />
25
+ <UserForm user={user} roles={roles} />
26
+ </>
27
+ );
28
+ }
@@ -0,0 +1,17 @@
1
+ import { UserForm } from "@/components/access/user-form";
2
+ import { PageHeader } from "@/components/dashboard/page-header";
3
+ import { listRoles } from "@/lib/access";
4
+ import { requirePermission } from "@/lib/dashboard";
5
+
6
+ export const metadata = { title: "Add user" };
7
+
8
+ export default async function NewUserPage() {
9
+ await requirePermission("users.create", "/dashboard/access/users/new");
10
+
11
+ return (
12
+ <>
13
+ <PageHeader title="Add user" crumbs={[{ label: "Dashboard", href: "/dashboard" }, { label: "Users", href: "/dashboard/access/users" }, { label: "Add" }]} />
14
+ <UserForm roles={await listRoles()} />
15
+ </>
16
+ );
17
+ }
@@ -0,0 +1,158 @@
1
+ import Link from "next/link";
2
+ import { PlusIcon, ShieldCheckIcon } from "lucide-react";
3
+ import { UserRowActions } from "@/components/access/user-row-actions";
4
+ import { UsersToolbar } from "@/components/access/users-toolbar";
5
+ import { LocalTime } from "@/components/dashboard/local-time";
6
+ import { PageHeader } from "@/components/dashboard/page-header";
7
+ import { UserAvatar } from "@/components/dashboard/user-menu";
8
+ import { Badge } from "@/components/ui/badge";
9
+ import { Button } from "@/components/ui/button";
10
+ import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty";
11
+ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
12
+ import { listRoles, listUsers } from "@/lib/access";
13
+ import { requirePermission } from "@/lib/dashboard";
14
+
15
+ export const metadata = { title: "Users" };
16
+
17
+ type Params = { q?: string; role?: string; status?: string; page?: string };
18
+
19
+ /** Everyone who can sign in: who they are, what roles they hold, and whether they still can. */
20
+ export default async function UsersPage({ searchParams }: { searchParams: Promise<Params> }) {
21
+ const { may } = await requirePermission("users.view", "/dashboard/access/users");
22
+ const params = await searchParams;
23
+ const [list, roles] = await Promise.all([listUsers(params), listRoles()]);
24
+ const users = list?.data ?? [];
25
+ const meta = list?.meta ?? { page: 1, perPage: 25, total: 0, totalPages: 1 };
26
+ const filtered = Boolean(params.q || params.role || params.status);
27
+
28
+ const pageHref = (page: number) => {
29
+ const query = new URLSearchParams(Object.entries({ ...params, page: String(page) }).filter(([, value]) => value) as [string, string][]);
30
+ return `/dashboard/access/users?${query}`;
31
+ };
32
+
33
+ return (
34
+ <>
35
+ <PageHeader
36
+ title="Users"
37
+ description="Everyone who can sign in, and the roles that say what they may do."
38
+ crumbs={[{ label: "Dashboard", href: "/dashboard" }, { label: "Users" }]}
39
+ actions={
40
+ may("users.create") && (
41
+ <Button asChild>
42
+ <Link href="/dashboard/access/users/new">
43
+ <PlusIcon />
44
+ Add user
45
+ </Link>
46
+ </Button>
47
+ )
48
+ }
49
+ />
50
+
51
+ <UsersToolbar roles={roles.map(({ id, name }) => ({ id, name }))} />
52
+
53
+ {users.length === 0 ? (
54
+ <Empty className="rounded-xl border border-dashed">
55
+ <EmptyHeader>
56
+ <EmptyTitle>{filtered ? "Nobody matches" : "No users"}</EmptyTitle>
57
+ <EmptyDescription>{filtered ? "Try a different search, role or status." : "Add the first person who should be able to sign in."}</EmptyDescription>
58
+ </EmptyHeader>
59
+ </Empty>
60
+ ) : (
61
+ <div className="overflow-x-auto rounded-xl border">
62
+ <Table>
63
+ <TableHeader>
64
+ <TableRow>
65
+ <TableHead>User</TableHead>
66
+ <TableHead>Roles</TableHead>
67
+ <TableHead>Status</TableHead>
68
+ <TableHead>Last active</TableHead>
69
+ <TableHead className="w-12">
70
+ <span className="sr-only">Actions</span>
71
+ </TableHead>
72
+ </TableRow>
73
+ </TableHeader>
74
+ <TableBody>
75
+ {users.map((user) => (
76
+ <TableRow key={user.id} className={user.active ? undefined : "text-muted-foreground"}>
77
+ <TableCell>
78
+ <div className="flex items-center gap-3">
79
+ <UserAvatar user={{ name: user.name, email: user.email, image: user.image }} className="size-8" />
80
+ <div className="flex min-w-0 flex-col">
81
+ <span className="flex items-center gap-2 truncate text-sm font-medium">
82
+ {may("users.edit") && (user.withinYours || user.isSelf) ? (
83
+ <Link href={`/dashboard/access/users/${encodeURIComponent(user.id)}`} className="truncate hover:underline">
84
+ {user.name || user.email}
85
+ </Link>
86
+ ) : (
87
+ <span className="truncate">{user.name || user.email}</span>
88
+ )}
89
+ {user.isSelf && <Badge variant="outline">You</Badge>}
90
+ </span>
91
+ <span className="truncate text-xs text-muted-foreground">{user.email}</span>
92
+ </div>
93
+ </div>
94
+ </TableCell>
95
+ <TableCell>
96
+ <div className="flex flex-wrap gap-1">
97
+ {user.roles.length === 0 && <span className="text-sm text-muted-foreground">None</span>}
98
+ {user.roles.map((role) => (
99
+ <Badge key={role.id} variant={user.isAdmin && role.name === "ADMIN" ? "default" : "secondary"}>
100
+ {role.name}
101
+ </Badge>
102
+ ))}
103
+ </div>
104
+ </TableCell>
105
+ <TableCell>
106
+ <div className="flex flex-wrap items-center gap-1">
107
+ <Badge variant={user.active ? "outline" : "secondary"}>{user.active ? "Active" : "Switched off"}</Badge>
108
+ {user.twoFactorEnabled && (
109
+ <Badge variant="outline" title="Signs in with a second step">
110
+ <ShieldCheckIcon />
111
+ 2-step
112
+ </Badge>
113
+ )}
114
+ </div>
115
+ </TableCell>
116
+ <TableCell className="text-sm text-muted-foreground">{user.lastActiveAt ? <LocalTime value={user.lastActiveAt} /> : "Not yet"}</TableCell>
117
+ <TableCell>
118
+ <UserRowActions user={user} canEdit={may("users.edit") && (user.withinYours || user.isSelf)} canDelete={may("users.delete") && user.withinYours} />
119
+ </TableCell>
120
+ </TableRow>
121
+ ))}
122
+ </TableBody>
123
+ </Table>
124
+ </div>
125
+ )}
126
+
127
+ {meta.total > 0 && (
128
+ <div className="flex items-center justify-between gap-3 text-sm text-muted-foreground">
129
+ <p className="tabular-nums">
130
+ {(meta.page - 1) * meta.perPage + 1}–{Math.min(meta.page * meta.perPage, meta.total)} of {meta.total.toLocaleString()} {meta.total === 1 ? "user" : "users"}
131
+ </p>
132
+ {meta.totalPages > 1 && (
133
+ <div className="flex items-center gap-2">
134
+ {meta.page > 1 ? (
135
+ <Button variant="outline" size="sm" asChild>
136
+ <Link href={pageHref(meta.page - 1)}>Previous</Link>
137
+ </Button>
138
+ ) : (
139
+ <Button variant="outline" size="sm" disabled>
140
+ Previous
141
+ </Button>
142
+ )}
143
+ {meta.page < meta.totalPages ? (
144
+ <Button variant="outline" size="sm" asChild>
145
+ <Link href={pageHref(meta.page + 1)}>Next</Link>
146
+ </Button>
147
+ ) : (
148
+ <Button variant="outline" size="sm" disabled>
149
+ Next
150
+ </Button>
151
+ )}
152
+ </div>
153
+ )}
154
+ </div>
155
+ )}
156
+ </>
157
+ );
158
+ }
@@ -1,11 +1,11 @@
1
1
  "use server";
2
2
 
3
3
  import { revalidatePath } from "next/cache";
4
- import { can, storedFields, type PolicyAction } from "@flaredev/core";
4
+ import { storedFields, type PolicyAction } from "@flaredev/core";
5
5
  import type { FieldIssue } from "@/lib/resource/store";
6
6
  import { toCsv } from "@/lib/csv";
7
7
  import { fileUrl } from "@/lib/files";
8
- import { resourcePath, dashboardSession, dashboardStore, policyFor } from "@/lib/dashboard";
8
+ import { allResources, resourcePath, dashboardSession, dashboardStore, mayDo } from "@/lib/dashboard";
9
9
 
10
10
  export type ActionResult<T = unknown> =
11
11
  | { ok: true; data: T }
@@ -14,13 +14,14 @@ export type ActionResult<T = unknown> =
14
14
  const forbidden = (message = "You don't have access to this."): ActionResult<never> => ({ ok: false, status: 403, error: message });
15
15
 
16
16
  /**
17
- * Session check, plus the display policy when the resource has one. Laravel's policy
18
- * is the real decision and answers 403 itself; this only saves a round trip.
17
+ * Session check, plus what the person's roles allow. Laravel's policy is the real
18
+ * decision and answers 403 itself; this only saves a round trip.
19
19
  */
20
20
  async function allowed(resourceName: string, action: PolicyAction): Promise<ActionResult<never> | undefined> {
21
- const { allowed: inAdmin, role } = await dashboardSession();
21
+ const { allowed: inAdmin } = await dashboardSession();
22
22
  if (!inAdmin) return forbidden();
23
- if (!can(policyFor(resourceName), role, action)) return forbidden(`Your role can't ${action} this record.`);
23
+ const resource = allResources().find((item) => item.name === resourceName);
24
+ if (!resource || !(await mayDo(resource, action))) return forbidden(`Your role can't ${action} this record.`);
24
25
  }
25
26
 
26
27
  export async function deleteRecordAction(resourceName: string, id: string): Promise<ActionResult<{ id: string }>> {
@@ -125,9 +126,9 @@ export async function createUploadUrlAction(
125
126
  fieldKey: string,
126
127
  file: { name: string; type: string; size: number },
127
128
  ): Promise<ActionResult<{ url: string; key: string }>> {
128
- const { allowed: inAdmin, role } = await dashboardSession();
129
- const policy = policyFor(resourceName);
130
- if (!inAdmin || !(can(policy, role, "create") || can(policy, role, "update"))) return forbidden("Your role can't add files to this record.");
129
+ const { allowed: inAdmin } = await dashboardSession();
130
+ const target = allResources().find((item) => item.name === resourceName);
131
+ if (!inAdmin || !target || !((await mayDo(target, "create")) || (await mayDo(target, "update")))) return forbidden("Your role can't add files to this record.");
131
132
 
132
133
  const field = dashboardStore(resourceName).resource.fields[fieldKey];
133
134
  if (!field || field.kind !== "file") return { ok: false, status: 404, error: "That isn't a file field." };
@@ -1,9 +1,9 @@
1
1
  "use server";
2
2
 
3
3
  import { revalidatePath } from "next/cache";
4
- import { can, type PolicyAction } from "@flaredev/core";
4
+ import type { PolicyAction } from "@flaredev/core";
5
5
  import type { Failure } from "@/lib/resource/store";
6
- import { resourcePath, dashboardSession, dashboardStore, policyFor } from "@/lib/dashboard";
6
+ import { allResources, resourcePath, dashboardSession, dashboardStore, mayDo } from "@/lib/dashboard";
7
7
  import type { ActionResult } from "./actions";
8
8
 
9
9
  /** One row that couldn't be saved, numbered from 1 within the batch that was sent. */
@@ -26,11 +26,12 @@ const GIVE_UP_AFTER = 25;
26
26
 
27
27
  const forbidden = (message = "You don't have access to the admin."): ActionResult<never> => ({ ok: false, status: 403, error: message });
28
28
 
29
- /** Session + policy check for one action on one resource. */
29
+ /** Session check, plus what the person's roles allow, for one action on one resource. */
30
30
  async function allowed(resourceName: string, action: PolicyAction): Promise<ActionResult<never> | undefined> {
31
- const { allowed: inAdmin, role } = await dashboardSession();
31
+ const { allowed: inAdmin } = await dashboardSession();
32
32
  if (!inAdmin) return forbidden();
33
- if (!can(policyFor(resourceName), role, action)) return forbidden(`Your role can't ${action} this record.`);
33
+ const resource = allResources().find((item) => item.name === resourceName);
34
+ if (!resource || !(await mayDo(resource, action))) return forbidden(`Your role can't ${action} this record.`);
34
35
  }
35
36
 
36
37
  /** A store failure as one line of text, attributed to a field when the issues name one. */