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.
- package/README.md +2 -0
- package/index.mjs +24 -10
- package/package.json +1 -1
- package/template/laravel/config/nevela.php +17 -2
- package/template/laravel/database/migrations/2026_10_06_000000_create_nevela_access_tables.php +79 -0
- package/template/laravel/src/Access/Access.php +168 -0
- package/template/laravel/src/Access/Permissions.php +271 -0
- package/template/laravel/src/Access/SampleUsers.php +81 -0
- package/template/laravel/src/Auth/Account.php +17 -1
- package/template/laravel/src/Console/SetupCommand.php +11 -2
- package/template/laravel/src/Console/UserCommand.php +151 -59
- package/template/laravel/src/Generator/ResourceGenerator.php +8 -7
- package/template/laravel/src/Http/Access/RolesAreSetUp.php +24 -0
- package/template/laravel/src/Http/Access/RolesController.php +197 -0
- package/template/laravel/src/Http/Access/UsersController.php +382 -0
- package/template/laravel/src/Http/Auth/AccountController.php +5 -1
- package/template/laravel/src/Models/Role.php +73 -0
- package/template/laravel/src/Nevela.php +2 -1
- package/template/laravel/src/NevelaServiceProvider.php +29 -0
- package/template/web/app/api/[...path]/route.ts +5 -1
- package/template/web/app/dashboard/access/actions.ts +70 -0
- package/template/web/app/dashboard/access/roles/[id]/page.tsx +32 -0
- package/template/web/app/dashboard/access/roles/new/page.tsx +18 -0
- package/template/web/app/dashboard/access/roles/page.tsx +77 -0
- package/template/web/app/dashboard/access/users/[id]/page.tsx +28 -0
- package/template/web/app/dashboard/access/users/new/page.tsx +17 -0
- package/template/web/app/dashboard/access/users/page.tsx +158 -0
- package/template/web/app/dashboard/actions.ts +10 -9
- package/template/web/app/dashboard/import-actions.ts +6 -5
- package/template/web/app/dashboard/layout.tsx +5 -2
- package/template/web/app/dashboard/page.tsx +27 -4
- package/template/web/components/access/role-form.tsx +389 -0
- package/template/web/components/access/user-form.tsx +167 -0
- package/template/web/components/access/user-row-actions.tsx +117 -0
- package/template/web/components/access/users-toolbar.tsx +82 -0
- package/template/web/components/dashboard/dashboard-sidebar.tsx +10 -2
- package/template/web/lib/access.ts +107 -0
- package/template/web/lib/dashboard.ts +51 -18
- package/template/web/lib/session.ts +11 -0
- package/template/web/package.json +1 -1
|
@@ -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 {
|
|
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,
|
|
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
|
|
18
|
-
*
|
|
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
|
|
21
|
+
const { allowed: inAdmin } = await dashboardSession();
|
|
22
22
|
if (!inAdmin) return forbidden();
|
|
23
|
-
|
|
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
|
|
129
|
-
const
|
|
130
|
-
if (!inAdmin || !(
|
|
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 {
|
|
4
|
+
import type { PolicyAction } from "@flaredev/core";
|
|
5
5
|
import type { Failure } from "@/lib/resource/store";
|
|
6
|
-
import { resourcePath, dashboardSession, dashboardStore,
|
|
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
|
|
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
|
|
31
|
+
const { allowed: inAdmin } = await dashboardSession();
|
|
32
32
|
if (!inAdmin) return forbidden();
|
|
33
|
-
|
|
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. */
|
|
@@ -4,7 +4,7 @@ import { Toaster } from "@/components/ui/sonner";
|
|
|
4
4
|
import { DashboardHeader, type Theme } from "@/components/dashboard/dashboard-header";
|
|
5
5
|
import { DashboardSidebar } from "@/components/dashboard/dashboard-sidebar";
|
|
6
6
|
import { dashboardLinks } from "@/lib/dashboard-nav";
|
|
7
|
-
import {
|
|
7
|
+
import { dashboardSession, visibleResources } from "@/lib/dashboard";
|
|
8
8
|
import { fileUrl } from "@/lib/files";
|
|
9
9
|
import { requireSession } from "@/lib/session";
|
|
10
10
|
import { site } from "@/lib/site";
|
|
@@ -31,10 +31,13 @@ export default async function DashboardLayout({ children }: { children: React.Re
|
|
|
31
31
|
}));
|
|
32
32
|
|
|
33
33
|
const account = { name: user.name, email: user.email, image: user.avatar ? fileUrl(user.avatar, "thumb") : null };
|
|
34
|
+
// The screens for managing people, for those whose roles allow it.
|
|
35
|
+
const { isAdmin, may } = await dashboardSession();
|
|
36
|
+
const access = { users: may("users.view"), roles: may("roles.view") };
|
|
34
37
|
|
|
35
38
|
return (
|
|
36
39
|
<SidebarProvider defaultOpen={!collapsed}>
|
|
37
|
-
<DashboardSidebar appName={site.name} resources={resources} links={dashboardLinks} isAdmin={
|
|
40
|
+
<DashboardSidebar appName={site.name} resources={resources} links={dashboardLinks} isAdmin={isAdmin} access={access} user={account} />
|
|
38
41
|
<SidebarInset>
|
|
39
42
|
<DashboardHeader user={account} initialTheme={theme} notices={[]} />
|
|
40
43
|
<main className="flex flex-1 flex-col gap-6 p-4 md:p-6 lg:p-8">{children}</main>
|
|
@@ -1,20 +1,24 @@
|
|
|
1
1
|
import Link from "next/link";
|
|
2
2
|
import { ArrowRightIcon, PlusIcon } from "lucide-react";
|
|
3
|
+
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
|
3
4
|
import { Button } from "@/components/ui/button";
|
|
4
5
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
5
6
|
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty";
|
|
6
7
|
import { PageHeader } from "@/components/dashboard/page-header";
|
|
7
8
|
import { resourceIcon } from "@/components/dashboard/resource-icon";
|
|
8
9
|
import { StatCards, type Stat } from "@/components/dashboard/stat-card";
|
|
9
|
-
import { adminPermissions, resourcePath, resourceStats, trend, visibleResources } from "@/lib/dashboard";
|
|
10
|
+
import { adminPermissions, allResources, resourcePath, resourceStats, trend, visibleResources } from "@/lib/dashboard";
|
|
10
11
|
import { requireSession } from "@/lib/session";
|
|
11
12
|
|
|
12
13
|
export const metadata = { title: "Dashboard" };
|
|
13
14
|
|
|
14
15
|
/** The first thing you see when you sign in: how much data there is, and what's left to set up. */
|
|
15
|
-
export default async function DashboardPage() {
|
|
16
|
+
export default async function DashboardPage({ searchParams }: { searchParams: Promise<{ error?: string }> }) {
|
|
16
17
|
const { user } = await requireSession("/dashboard");
|
|
17
18
|
const resources = await visibleResources();
|
|
19
|
+
// Resources exist, and this person's roles show them none: not the same as an empty app.
|
|
20
|
+
const noneForYou = resources.length === 0 && allResources().length > 0;
|
|
21
|
+
const turnedBack = (await searchParams).error === "forbidden";
|
|
18
22
|
|
|
19
23
|
const cards = await Promise.all(
|
|
20
24
|
resources.map(async (resource) => {
|
|
@@ -37,12 +41,31 @@ export default async function DashboardPage() {
|
|
|
37
41
|
<>
|
|
38
42
|
<PageHeader
|
|
39
43
|
title={`Welcome${user.name ? `, ${user.name.split(" ")[0]}` : ""}`}
|
|
40
|
-
description={resources.length > 0 ? "Everything in your app, and how it's moving." : "Your app is running. Add a resource to fill this page."}
|
|
44
|
+
description={resources.length > 0 ? "Everything in your app, and how it's moving." : noneForYou ? "You're signed in." : "Your app is running. Add a resource to fill this page."}
|
|
41
45
|
/>
|
|
42
46
|
|
|
47
|
+
{turnedBack && (
|
|
48
|
+
<Alert>
|
|
49
|
+
<AlertTitle>Your role doesn't include that page</AlertTitle>
|
|
50
|
+
<AlertDescription>If you need it, ask an administrator to add it to your role.</AlertDescription>
|
|
51
|
+
</Alert>
|
|
52
|
+
)}
|
|
53
|
+
|
|
43
54
|
<StatCards stats={stats} />
|
|
44
55
|
|
|
45
|
-
{
|
|
56
|
+
{noneForYou && (
|
|
57
|
+
<Empty className="rounded-xl border border-dashed">
|
|
58
|
+
<EmptyHeader>
|
|
59
|
+
<EmptyTitle>Nothing to show yet</EmptyTitle>
|
|
60
|
+
<EmptyDescription>
|
|
61
|
+
Your role doesn't include any of this app's data. You can manage <Link href="/dashboard/account" className="underline">your account</Link>, and an
|
|
62
|
+
administrator can give you access to more.
|
|
63
|
+
</EmptyDescription>
|
|
64
|
+
</EmptyHeader>
|
|
65
|
+
</Empty>
|
|
66
|
+
)}
|
|
67
|
+
|
|
68
|
+
{resources.length === 0 && !noneForYou && (
|
|
46
69
|
<Empty className="rounded-xl border border-dashed">
|
|
47
70
|
<EmptyHeader>
|
|
48
71
|
<EmptyTitle>No resources yet</EmptyTitle>
|