@percepta/create 3.5.2 → 3.6.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/dist/index.js +61 -12
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/template-versions.json +1 -1
- package/templates/monorepo/package.json.template +1 -1
- package/templates/webapp/README.md +2 -0
- package/templates/webapp/e2e/rbac.spec.ts +28 -26
- package/templates/webapp/package.json.template +2 -2
- package/templates/webapp/src/app/(app)/layout.tsx +4 -8
- package/templates/webapp/src/app/(app)/page.tsx +148 -7
- package/templates/webapp/src/app/(auth)/auth/signin/CredentialsSignInForm.tsx +29 -11
- package/templates/webapp/src/app/(auth)/auth/signup/CredentialsSignUpForm.tsx +34 -12
- package/templates/webapp/src/app/(auth)/layout.tsx +30 -4
- package/templates/webapp/src/app/{(admin) → (settings)}/layout.tsx +6 -10
- package/templates/webapp/src/app/{(admin)/admin/_components/AdminTabs.tsx → (settings)/settings/_components/AccessControlTabs.tsx} +10 -6
- package/templates/webapp/src/app/{(admin)/admin/_lib/accessAdmin.ts → (settings)/settings/_lib/accessSettings.ts} +6 -6
- package/templates/webapp/src/app/{(admin)/admin → (settings)/settings}/page.tsx +99 -52
- package/templates/webapp/src/app/layout.tsx +1 -1
- package/templates/webapp/src/components/Header.tsx +27 -16
- package/templates/webapp/src/styles/globals.css +785 -8
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
} from "@percepta/design";
|
|
21
21
|
import type { Metadata } from "next";
|
|
22
22
|
import { revalidatePath } from "next/cache";
|
|
23
|
+
import Link from "next/link";
|
|
23
24
|
import { notFound } from "next/navigation";
|
|
24
25
|
import {
|
|
25
26
|
accessManifest,
|
|
@@ -32,28 +33,34 @@ import {
|
|
|
32
33
|
} from "../../../services/access/AppAccessControl";
|
|
33
34
|
import {
|
|
34
35
|
type AccessAppRole,
|
|
35
|
-
type
|
|
36
|
+
type SettingsPermissions,
|
|
36
37
|
assignableRoles,
|
|
37
38
|
readAssignableRole,
|
|
38
|
-
|
|
39
|
-
} from "./_lib/
|
|
40
|
-
import {
|
|
39
|
+
requireAnySettingsPermission,
|
|
40
|
+
} from "./_lib/accessSettings";
|
|
41
|
+
import {
|
|
42
|
+
AccessControlTabs,
|
|
43
|
+
type AccessControlTab,
|
|
44
|
+
} from "./_components/AccessControlTabs";
|
|
41
45
|
|
|
42
46
|
export const metadata: Metadata = {
|
|
43
|
-
title: "
|
|
44
|
-
description: "
|
|
47
|
+
title: "Settings - __APP_TITLE__",
|
|
48
|
+
description: "Settings - __APP_TITLE__",
|
|
45
49
|
};
|
|
46
50
|
|
|
47
51
|
type ApplicationAccessRole = ApplicationGrant["relation"];
|
|
48
52
|
|
|
49
|
-
interface
|
|
53
|
+
interface SettingsPageProps {
|
|
50
54
|
readonly searchParams?: Promise<{
|
|
55
|
+
readonly section?: string | string[];
|
|
51
56
|
readonly tab?: string | string[];
|
|
52
57
|
}>;
|
|
53
58
|
}
|
|
54
59
|
|
|
60
|
+
type SettingsSection = "access-control" | "audit-log";
|
|
61
|
+
|
|
55
62
|
interface RoleDefinition extends AccessRoleDefinition {
|
|
56
|
-
readonly source: "Application access" | "App
|
|
63
|
+
readonly source: "Application access" | "App roles";
|
|
57
64
|
}
|
|
58
65
|
|
|
59
66
|
interface PrincipalAssignmentOption extends PrincipalOption {
|
|
@@ -61,7 +68,7 @@ interface PrincipalAssignmentOption extends PrincipalOption {
|
|
|
61
68
|
readonly subject: SubjectRef;
|
|
62
69
|
}
|
|
63
70
|
|
|
64
|
-
interface
|
|
71
|
+
interface RoleAssignmentRow {
|
|
65
72
|
readonly definition: RoleDefinition;
|
|
66
73
|
readonly disabled: boolean;
|
|
67
74
|
readonly disabledReason?: string;
|
|
@@ -120,48 +127,73 @@ const roleDefinitions = [
|
|
|
120
127
|
(definition): RoleDefinition => ({
|
|
121
128
|
...definition,
|
|
122
129
|
role: definition.role,
|
|
123
|
-
source: "App
|
|
130
|
+
source: "App roles",
|
|
124
131
|
}),
|
|
125
132
|
),
|
|
126
133
|
] as const;
|
|
127
134
|
|
|
128
135
|
const appRoleDefinitionMap = new Map<AccessAppRole, RoleDefinition>(
|
|
129
136
|
roleDefinitions
|
|
130
|
-
.filter((definition) => definition.source === "App
|
|
137
|
+
.filter((definition) => definition.source === "App roles")
|
|
131
138
|
.map((definition) => [definition.role as AccessAppRole, definition]),
|
|
132
139
|
);
|
|
133
140
|
|
|
134
|
-
export default async function
|
|
135
|
-
const permissions = await
|
|
141
|
+
export default async function SettingsPage({ searchParams }: SettingsPageProps) {
|
|
142
|
+
const permissions = await requireAnySettingsPermission();
|
|
136
143
|
const params = await searchParams;
|
|
137
|
-
const
|
|
144
|
+
const section = readSettingsSection(params?.section);
|
|
145
|
+
const tab = readAccessControlTab(params?.tab);
|
|
138
146
|
|
|
139
147
|
return (
|
|
140
|
-
<div className="
|
|
141
|
-
<
|
|
142
|
-
<div>
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
148
|
+
<div className="app-settings">
|
|
149
|
+
<aside className="app-settings-sidebar" aria-label="Settings navigation">
|
|
150
|
+
<div className="app-settings-sidebar-title">Settings</div>
|
|
151
|
+
<Link
|
|
152
|
+
className={getSettingsNavClassName(section === "access-control")}
|
|
153
|
+
href="/settings"
|
|
154
|
+
>
|
|
155
|
+
Access control
|
|
156
|
+
</Link>
|
|
157
|
+
<Link
|
|
158
|
+
className={getSettingsNavClassName(section === "audit-log")}
|
|
159
|
+
href="/settings?section=audit-log"
|
|
160
|
+
>
|
|
161
|
+
Audit log
|
|
162
|
+
</Link>
|
|
163
|
+
</aside>
|
|
164
|
+
|
|
165
|
+
<div className="app-settings-content">
|
|
166
|
+
{section === "access-control" ? (
|
|
167
|
+
<section className="app-settings-section">
|
|
168
|
+
<div className="app-settings-header">
|
|
169
|
+
<p className="app-kicker">Settings</p>
|
|
170
|
+
<h1 className="app-title">Access control</h1>
|
|
171
|
+
<p className="app-subtitle">
|
|
172
|
+
{accessManifest.application.displayName}
|
|
173
|
+
</p>
|
|
174
|
+
</div>
|
|
175
|
+
|
|
176
|
+
<AccessControlTabs activeTab={tab} />
|
|
177
|
+
|
|
178
|
+
{tab === "roles" ? (
|
|
179
|
+
<RolesTab />
|
|
180
|
+
) : (
|
|
181
|
+
<AssignmentsTab permissions={permissions} />
|
|
182
|
+
)}
|
|
183
|
+
</section>
|
|
184
|
+
) : (
|
|
185
|
+
<section className="app-settings-section app-settings-placeholder">
|
|
186
|
+
<div className="app-settings-header">
|
|
187
|
+
<p className="app-kicker">Settings</p>
|
|
188
|
+
<h1 className="app-title">Audit log</h1>
|
|
189
|
+
<p className="app-subtitle">
|
|
190
|
+
{accessManifest.application.displayName}
|
|
191
|
+
</p>
|
|
192
|
+
</div>
|
|
193
|
+
<p className="app-review-meta">Coming soon</p>
|
|
194
|
+
</section>
|
|
195
|
+
)}
|
|
156
196
|
</div>
|
|
157
|
-
|
|
158
|
-
<AdminTabs activeTab={tab} />
|
|
159
|
-
|
|
160
|
-
{tab === "roles" ? (
|
|
161
|
-
<RolesTab />
|
|
162
|
-
) : (
|
|
163
|
-
<AssignmentsTab permissions={permissions} />
|
|
164
|
-
)}
|
|
165
197
|
</div>
|
|
166
198
|
);
|
|
167
199
|
}
|
|
@@ -210,9 +242,9 @@ function RolesTab() {
|
|
|
210
242
|
async function AssignmentsTab({
|
|
211
243
|
permissions,
|
|
212
244
|
}: {
|
|
213
|
-
readonly permissions:
|
|
245
|
+
readonly permissions: SettingsPermissions;
|
|
214
246
|
}) {
|
|
215
|
-
const rows = await
|
|
247
|
+
const rows = await listRoleAssignmentRows(permissions);
|
|
216
248
|
|
|
217
249
|
return (
|
|
218
250
|
<div className="rounded-md border border-border">
|
|
@@ -276,7 +308,7 @@ async function updateAdminAssignments(formData: FormData) {
|
|
|
276
308
|
async function updateAssignableRoleAssignments(formData: FormData) {
|
|
277
309
|
"use server";
|
|
278
310
|
|
|
279
|
-
const permissions = await
|
|
311
|
+
const permissions = await requireAnySettingsPermission();
|
|
280
312
|
const role = readAssignableRole(formData);
|
|
281
313
|
|
|
282
314
|
if (!canEditAppRole(role, permissions)) {
|
|
@@ -299,14 +331,14 @@ async function updateAssignableRoleAssignments(formData: FormData) {
|
|
|
299
331
|
permissions.canManageDefaultRoles ? undefined : { currentSubjects },
|
|
300
332
|
);
|
|
301
333
|
|
|
302
|
-
revalidatePath("/
|
|
334
|
+
revalidatePath("/settings");
|
|
303
335
|
}
|
|
304
336
|
|
|
305
337
|
async function updateApplicationAccessAssignments(
|
|
306
338
|
formData: FormData,
|
|
307
339
|
relation: ApplicationGrant["relation"],
|
|
308
340
|
) {
|
|
309
|
-
const permissions = await
|
|
341
|
+
const permissions = await requireAnySettingsPermission();
|
|
310
342
|
if (!permissions.canManageDefaultRoles) {
|
|
311
343
|
notFound();
|
|
312
344
|
}
|
|
@@ -330,16 +362,16 @@ async function updateApplicationAccessAssignments(
|
|
|
330
362
|
);
|
|
331
363
|
}
|
|
332
364
|
|
|
333
|
-
revalidatePath("/
|
|
365
|
+
revalidatePath("/settings");
|
|
334
366
|
}
|
|
335
367
|
|
|
336
|
-
async function
|
|
337
|
-
permissions:
|
|
338
|
-
): Promise<readonly
|
|
368
|
+
async function listRoleAssignmentRows(
|
|
369
|
+
permissions: SettingsPermissions,
|
|
370
|
+
): Promise<readonly RoleAssignmentRow[]> {
|
|
339
371
|
const principals = await listPrincipalAccessRows(permissions);
|
|
340
372
|
const principalOptions = principals.map(toPrincipalOption);
|
|
341
373
|
|
|
342
|
-
const applicationRows: readonly
|
|
374
|
+
const applicationRows: readonly RoleAssignmentRow[] = [
|
|
343
375
|
{
|
|
344
376
|
definition: getApplicationAccessRoleDefinition("user"),
|
|
345
377
|
disabled: !permissions.canManageDefaultRoles,
|
|
@@ -372,7 +404,7 @@ async function listRbacRoleAssignmentRows(
|
|
|
372
404
|
},
|
|
373
405
|
];
|
|
374
406
|
|
|
375
|
-
const appRoleRows = assignableRoles.map((role):
|
|
407
|
+
const appRoleRows = assignableRoles.map((role): RoleAssignmentRow => {
|
|
376
408
|
const canEditRole = canEditAppRole(role, permissions);
|
|
377
409
|
const selectedSubjects = sortSubjectsByPrincipal(
|
|
378
410
|
principals
|
|
@@ -407,7 +439,7 @@ async function listRbacRoleAssignmentRows(
|
|
|
407
439
|
}
|
|
408
440
|
|
|
409
441
|
async function listPrincipalAccessRows(
|
|
410
|
-
permissions:
|
|
442
|
+
permissions: SettingsPermissions,
|
|
411
443
|
): Promise<readonly PrincipalAccessRow[]> {
|
|
412
444
|
const [principals, grants, roleSubjects] = await Promise.all([
|
|
413
445
|
listPrincipals(),
|
|
@@ -484,7 +516,7 @@ function buildGrantMap(
|
|
|
484
516
|
|
|
485
517
|
function canEditAppRole(
|
|
486
518
|
role: AccessAppRole,
|
|
487
|
-
permissions:
|
|
519
|
+
permissions: SettingsPermissions,
|
|
488
520
|
): boolean {
|
|
489
521
|
return (
|
|
490
522
|
appRoleDefinitionMap.has(role) === true && permissions.canManageAppRoles
|
|
@@ -592,7 +624,22 @@ function sortSubjectsByPrincipal(
|
|
|
592
624
|
);
|
|
593
625
|
}
|
|
594
626
|
|
|
595
|
-
function
|
|
627
|
+
function readAccessControlTab(
|
|
628
|
+
tab: string | string[] | undefined,
|
|
629
|
+
): AccessControlTab {
|
|
596
630
|
const value = Array.isArray(tab) ? tab[0] : tab;
|
|
597
631
|
return value === "assignments" ? "assignments" : "roles";
|
|
598
632
|
}
|
|
633
|
+
|
|
634
|
+
function readSettingsSection(
|
|
635
|
+
section: string | string[] | undefined,
|
|
636
|
+
): SettingsSection {
|
|
637
|
+
const value = Array.isArray(section) ? section[0] : section;
|
|
638
|
+
return value === "audit-log" ? "audit-log" : "access-control";
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function getSettingsNavClassName(active: boolean): string {
|
|
642
|
+
return active
|
|
643
|
+
? "app-settings-nav-item app-settings-nav-active"
|
|
644
|
+
: "app-settings-nav-item";
|
|
645
|
+
}
|
|
@@ -9,7 +9,7 @@ export default function RootLayout({
|
|
|
9
9
|
children: React.ReactNode;
|
|
10
10
|
}) {
|
|
11
11
|
return (
|
|
12
|
-
<html lang="en">
|
|
12
|
+
<html lang="en" data-pcd-theme="__MOSAIC_DESIGN_THEME__">
|
|
13
13
|
<body className="antialiased" suppressHydrationWarning={true}>
|
|
14
14
|
<Providers>{children}</Providers>
|
|
15
15
|
</body>
|
|
@@ -8,18 +8,20 @@ import {
|
|
|
8
8
|
DropdownMenuSeparator,
|
|
9
9
|
DropdownMenuTrigger,
|
|
10
10
|
} from "@percepta/design";
|
|
11
|
-
import { LogOut } from "lucide-react";
|
|
11
|
+
import { LogOut, Search } from "lucide-react";
|
|
12
12
|
import Link from "next/link";
|
|
13
13
|
import React, { useCallback } from "react";
|
|
14
14
|
import { authClient } from "../lib/auth-client";
|
|
15
15
|
|
|
16
16
|
export default function Header({
|
|
17
|
-
|
|
17
|
+
showSettingsLink,
|
|
18
18
|
}: {
|
|
19
|
-
readonly
|
|
19
|
+
readonly showSettingsLink: boolean;
|
|
20
20
|
}) {
|
|
21
21
|
const { data: session } = authClient.useSession();
|
|
22
22
|
const user = session?.user;
|
|
23
|
+
const appTitle = "__APP_TITLE__";
|
|
24
|
+
const appInitial = appTitle.slice(0, 1).toUpperCase();
|
|
23
25
|
|
|
24
26
|
const handleSignOut = useCallback(() => {
|
|
25
27
|
void authClient.signOut({
|
|
@@ -36,36 +38,45 @@ export default function Header({
|
|
|
36
38
|
}
|
|
37
39
|
|
|
38
40
|
return (
|
|
39
|
-
<header className="
|
|
40
|
-
<nav aria-label="Primary navigation" className="
|
|
41
|
+
<header className="app-header">
|
|
42
|
+
<nav aria-label="Primary navigation" className="app-header-nav">
|
|
41
43
|
<Link
|
|
42
|
-
className="
|
|
44
|
+
className="app-header-brand"
|
|
43
45
|
href="/"
|
|
44
46
|
>
|
|
45
|
-
|
|
47
|
+
<span className="app-header-mark" aria-hidden={true}>
|
|
48
|
+
{appInitial}
|
|
49
|
+
</span>
|
|
50
|
+
<span>{appTitle}</span>
|
|
46
51
|
</Link>
|
|
47
|
-
{
|
|
48
|
-
<Link
|
|
49
|
-
|
|
50
|
-
href="/admin"
|
|
51
|
-
>
|
|
52
|
-
Admin
|
|
52
|
+
{showSettingsLink ? (
|
|
53
|
+
<Link className="app-nav-link" href="/settings">
|
|
54
|
+
Settings
|
|
53
55
|
</Link>
|
|
54
56
|
) : null}
|
|
55
57
|
</nav>
|
|
58
|
+
<div className="app-header-search" aria-hidden={true}>
|
|
59
|
+
<div className="flex items-center justify-between gap-3">
|
|
60
|
+
<span className="inline-flex items-center gap-2">
|
|
61
|
+
<Search className="size-4" />
|
|
62
|
+
Search
|
|
63
|
+
</span>
|
|
64
|
+
<span className="text-xs">Cmd K</span>
|
|
65
|
+
</div>
|
|
66
|
+
</div>
|
|
56
67
|
<DropdownMenu>
|
|
57
68
|
<DropdownMenuTrigger asChild={true}>
|
|
58
69
|
<button
|
|
59
|
-
className="-
|
|
70
|
+
className="app-account-button"
|
|
60
71
|
aria-label="Open account menu"
|
|
61
72
|
>
|
|
62
|
-
<div className="
|
|
73
|
+
<div className="app-account-text">
|
|
63
74
|
<p className="text-sm font-medium text-foreground">
|
|
64
75
|
{user.name || "User"}
|
|
65
76
|
</p>
|
|
66
77
|
<p className="text-xs text-muted-foreground">{user.email}</p>
|
|
67
78
|
</div>
|
|
68
|
-
<div className="
|
|
79
|
+
<div className="app-avatar">
|
|
69
80
|
{(user.name || user.email || "U").slice(0, 1).toUpperCase()}
|
|
70
81
|
</div>
|
|
71
82
|
</button>
|