@percepta/create 3.5.1 → 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 +59 -25
- 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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
2
|
import { promisify } from "node:util";
|
|
3
|
-
import { expect, test, type Page } from "@playwright/test";
|
|
3
|
+
import { expect, test, type Browser, type Page } from "@playwright/test";
|
|
4
4
|
|
|
5
5
|
const execFileAsync = promisify(execFile);
|
|
6
6
|
const password = "password";
|
|
@@ -13,14 +13,17 @@ const users = {
|
|
|
13
13
|
} as const;
|
|
14
14
|
|
|
15
15
|
test.describe("RBAC access", () => {
|
|
16
|
-
test("customer admin can manage role mappings
|
|
16
|
+
test("customer admin can manage role mappings and change app access", async ({
|
|
17
|
+
browser,
|
|
17
18
|
page,
|
|
18
19
|
}) => {
|
|
19
20
|
test.setTimeout(60_000);
|
|
20
|
-
await signIn(page, users.customerAdmin, "/
|
|
21
|
+
await signIn(page, users.customerAdmin, "/settings?tab=assignments");
|
|
21
22
|
|
|
22
|
-
await expect(
|
|
23
|
-
|
|
23
|
+
await expect(
|
|
24
|
+
page.getByRole("heading", { name: "Access control" }),
|
|
25
|
+
).toBeVisible();
|
|
26
|
+
await expect(page.getByRole("link", { name: "Settings" })).toBeVisible();
|
|
24
27
|
|
|
25
28
|
const userAssignments = page.getByRole("button", {
|
|
26
29
|
name: "User assignments",
|
|
@@ -46,6 +49,8 @@ test.describe("RBAC access", () => {
|
|
|
46
49
|
await expect(
|
|
47
50
|
page.getByRole("button", { name: "User assignments" }),
|
|
48
51
|
).toContainText("App Non User");
|
|
52
|
+
|
|
53
|
+
await expectAppAccess(browser, users.nonAppUser, true);
|
|
49
54
|
} finally {
|
|
50
55
|
if (shouldResetSeedData) {
|
|
51
56
|
await resetSeedData();
|
|
@@ -61,19 +66,21 @@ test.describe("RBAC access", () => {
|
|
|
61
66
|
})
|
|
62
67
|
.not.toContain("App Non User");
|
|
63
68
|
|
|
69
|
+
await expectAppAccess(browser, users.nonAppUser, false);
|
|
70
|
+
|
|
64
71
|
await page.goto("/");
|
|
65
|
-
await expect(page
|
|
66
|
-
0,
|
|
67
|
-
);
|
|
72
|
+
await expect(getAppHomeHeading(page)).toHaveCount(0);
|
|
68
73
|
await expectNotFound(page);
|
|
69
74
|
});
|
|
70
75
|
|
|
71
|
-
test("app admin can see
|
|
76
|
+
test("app admin can see access control and main app but cannot edit default role mappings", async ({
|
|
72
77
|
page,
|
|
73
78
|
}) => {
|
|
74
|
-
await signIn(page, users.appAdmin, "/
|
|
79
|
+
await signIn(page, users.appAdmin, "/settings?tab=assignments");
|
|
75
80
|
|
|
76
|
-
await expect(
|
|
81
|
+
await expect(
|
|
82
|
+
page.getByRole("heading", { name: "Access control" }),
|
|
83
|
+
).toBeVisible();
|
|
77
84
|
await expect(
|
|
78
85
|
page
|
|
79
86
|
.getByText("Only customer admins can edit default application roles.")
|
|
@@ -88,33 +95,31 @@ test.describe("RBAC access", () => {
|
|
|
88
95
|
).toHaveAttribute("aria-disabled", "true");
|
|
89
96
|
|
|
90
97
|
await page.goto("/");
|
|
91
|
-
await expect(
|
|
92
|
-
page.getByRole("heading", { name: /Welcome to/ }),
|
|
93
|
-
).toBeVisible();
|
|
98
|
+
await expect(getAppHomeHeading(page)).toBeVisible();
|
|
94
99
|
});
|
|
95
100
|
|
|
96
|
-
test("app user can enter the main app but cannot reach
|
|
101
|
+
test("app user can enter the main app but cannot reach settings", async ({
|
|
97
102
|
page,
|
|
98
103
|
}) => {
|
|
99
104
|
await signIn(page, users.appUser, "/");
|
|
100
105
|
|
|
101
|
-
await expect(
|
|
102
|
-
|
|
103
|
-
).toBeVisible();
|
|
104
|
-
await expect(page.getByRole("link", { name: "Admin" })).toHaveCount(0);
|
|
106
|
+
await expect(getAppHomeHeading(page)).toBeVisible();
|
|
107
|
+
await expect(page.getByRole("link", { name: "Settings" })).toHaveCount(0);
|
|
105
108
|
|
|
106
|
-
await page.goto("/
|
|
107
|
-
await expect(
|
|
109
|
+
await page.goto("/settings");
|
|
110
|
+
await expect(
|
|
111
|
+
page.getByRole("heading", { name: "Access control" }),
|
|
112
|
+
).toHaveCount(0);
|
|
108
113
|
await expectNotFound(page);
|
|
109
114
|
});
|
|
110
115
|
|
|
111
116
|
test("non app user cannot enter the app", async ({ page }) => {
|
|
112
117
|
await signIn(page, users.nonAppUser, "/");
|
|
113
118
|
|
|
114
|
-
await expect(page
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
119
|
+
await expect(getAppHomeHeading(page)).toHaveCount(0);
|
|
120
|
+
await expect(
|
|
121
|
+
page.getByRole("heading", { name: "Access control" }),
|
|
122
|
+
).toHaveCount(0);
|
|
118
123
|
await expectNotFound(page);
|
|
119
124
|
});
|
|
120
125
|
});
|
|
@@ -135,6 +140,35 @@ async function expectNotFound(page: Page) {
|
|
|
135
140
|
).toBeVisible();
|
|
136
141
|
}
|
|
137
142
|
|
|
143
|
+
function getAppHomeHeading(page: Page) {
|
|
144
|
+
return page.getByRole("heading", { name: /Good afternoon/ });
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function expectAppAccess(
|
|
148
|
+
browser: Browser,
|
|
149
|
+
email: string,
|
|
150
|
+
expectedAccess: boolean,
|
|
151
|
+
) {
|
|
152
|
+
const expectedHeadingCount = expectedAccess ? 1 : 0;
|
|
153
|
+
|
|
154
|
+
await expect
|
|
155
|
+
.poll(
|
|
156
|
+
async () => {
|
|
157
|
+
const context = await browser.newContext();
|
|
158
|
+
const page = await context.newPage();
|
|
159
|
+
|
|
160
|
+
try {
|
|
161
|
+
await signIn(page, email, "/");
|
|
162
|
+
return await getAppHomeHeading(page).count();
|
|
163
|
+
} finally {
|
|
164
|
+
await context.close();
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
{ timeout: 15_000 },
|
|
168
|
+
)
|
|
169
|
+
.toBe(expectedHeadingCount);
|
|
170
|
+
}
|
|
171
|
+
|
|
138
172
|
async function resetSeedData() {
|
|
139
173
|
await execFileAsync("pnpm", ["db:seed"], { cwd: process.cwd() });
|
|
140
174
|
}
|
|
@@ -52,10 +52,10 @@
|
|
|
52
52
|
"@opentelemetry/exporter-trace-otlp-proto": "^0.203.0",
|
|
53
53
|
"@opentelemetry/sdk-node": "^0.203.0",
|
|
54
54
|
"@__REPO_NAME__/auth": "workspace:*",
|
|
55
|
-
"@percepta/access-control": "0.
|
|
55
|
+
"@percepta/access-control": "0.8.0",
|
|
56
56
|
"@percepta/ai": "0.1.0",
|
|
57
57
|
"@percepta/database": "0.1.1",
|
|
58
|
-
"@percepta/design": "0.
|
|
58
|
+
"@percepta/design": "0.4.0",
|
|
59
59
|
"@percepta/inngest": "0.1.0",
|
|
60
60
|
"@percepta/logger": "0.1.0",
|
|
61
61
|
"@percepta/next-utils": "0.2.1",
|
|
@@ -20,7 +20,7 @@ export default async function AppLayout({ children }: { children: ReactNode }) {
|
|
|
20
20
|
canManageDefaultRoles(session.user.id),
|
|
21
21
|
canManageAppRoles(session.user.id),
|
|
22
22
|
]);
|
|
23
|
-
const
|
|
23
|
+
const showSettingsLink = canManageDefaults || canManageRoles;
|
|
24
24
|
|
|
25
25
|
if (!canAccessApp) {
|
|
26
26
|
notFound();
|
|
@@ -28,13 +28,9 @@ export default async function AppLayout({ children }: { children: ReactNode }) {
|
|
|
28
28
|
|
|
29
29
|
return (
|
|
30
30
|
<>
|
|
31
|
-
<Header
|
|
32
|
-
<main>
|
|
33
|
-
<div className="
|
|
34
|
-
<div className="mx-auto max-w-6xl">
|
|
35
|
-
<div className="rounded-lg bg-white p-8">{children}</div>
|
|
36
|
-
</div>
|
|
37
|
-
</div>
|
|
31
|
+
<Header showSettingsLink={showSettingsLink} />
|
|
32
|
+
<main className="app-shell-main">
|
|
33
|
+
<div className="app-home">{children}</div>
|
|
38
34
|
</main>
|
|
39
35
|
</>
|
|
40
36
|
);
|
|
@@ -5,15 +5,156 @@ export const metadata: Metadata = {
|
|
|
5
5
|
description: "__APP_TITLE__",
|
|
6
6
|
};
|
|
7
7
|
|
|
8
|
+
const metrics = [
|
|
9
|
+
{ label: "Open projects", value: "12", delta: "+2", tone: "good" },
|
|
10
|
+
{ label: "Tasks this week", value: "47", delta: "+8%", tone: "good" },
|
|
11
|
+
{ label: "Time saved", value: "36h", delta: "+12%", tone: "good" },
|
|
12
|
+
{ label: "On-track rate", value: "91%", delta: "-2%", tone: "warn" },
|
|
13
|
+
] as const;
|
|
14
|
+
|
|
15
|
+
const activity = [
|
|
16
|
+
{
|
|
17
|
+
item: "Q2 procurement plan",
|
|
18
|
+
owner: "Mira K.",
|
|
19
|
+
status: "Approved",
|
|
20
|
+
updated: "12m ago",
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
item: "Inventory forecast - West",
|
|
24
|
+
owner: "System",
|
|
25
|
+
status: "Running",
|
|
26
|
+
updated: "24m ago",
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
item: "Allergen contamination rules",
|
|
30
|
+
owner: "Devon A.",
|
|
31
|
+
status: "Review",
|
|
32
|
+
updated: "1h ago",
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
item: "Cocoa supplier - ABC Inc.",
|
|
36
|
+
owner: "System",
|
|
37
|
+
status: "Flagged",
|
|
38
|
+
updated: "2h ago",
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
item: "New brand line variant",
|
|
42
|
+
owner: "Priya S.",
|
|
43
|
+
status: "Draft",
|
|
44
|
+
updated: "3h ago",
|
|
45
|
+
},
|
|
46
|
+
] as const;
|
|
47
|
+
|
|
48
|
+
const reviews = [
|
|
49
|
+
{ title: "Supplier change - Cocoa", due: "Due in 2 days" },
|
|
50
|
+
{ title: "Allergen rule update", due: "Due in 5 days" },
|
|
51
|
+
{ title: "Q2 production sequence", due: "Due in 9 days" },
|
|
52
|
+
] as const;
|
|
53
|
+
|
|
8
54
|
export default function HomePage() {
|
|
9
55
|
return (
|
|
10
|
-
<div className="
|
|
11
|
-
<
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
56
|
+
<div className="app-home-screen">
|
|
57
|
+
<section className="app-home-hero">
|
|
58
|
+
<div className="app-home-heading">
|
|
59
|
+
<p className="app-kicker">Overview</p>
|
|
60
|
+
<h1 className="app-title">Good afternoon, App Admin</h1>
|
|
61
|
+
<p className="app-subtitle">
|
|
62
|
+
Welcome to __APP_TITLE__. Three items want attention; the workspace
|
|
63
|
+
is ready for your team's first workflows.
|
|
64
|
+
</p>
|
|
65
|
+
</div>
|
|
66
|
+
<div className="app-actions">
|
|
67
|
+
<span className="app-button-secondary">Last 7 days</span>
|
|
68
|
+
<span className="app-button-primary">New workflow</span>
|
|
69
|
+
</div>
|
|
70
|
+
</section>
|
|
71
|
+
|
|
72
|
+
<section className="app-dashboard" aria-label="Workspace overview">
|
|
73
|
+
<div className="app-metrics">
|
|
74
|
+
{metrics.map((metric) => (
|
|
75
|
+
<article className="app-metric-card" key={metric.label}>
|
|
76
|
+
<div className="app-metric-top">
|
|
77
|
+
<span className="app-metric-label">{metric.label}</span>
|
|
78
|
+
<span
|
|
79
|
+
className={
|
|
80
|
+
metric.tone === "warn" ? "app-chip app-chip-warn" : "app-chip"
|
|
81
|
+
}
|
|
82
|
+
>
|
|
83
|
+
{metric.delta}
|
|
84
|
+
</span>
|
|
85
|
+
</div>
|
|
86
|
+
<div className="app-metric-value">{metric.value}</div>
|
|
87
|
+
<div className="app-sparkline" aria-hidden={true} />
|
|
88
|
+
</article>
|
|
89
|
+
))}
|
|
90
|
+
</div>
|
|
91
|
+
|
|
92
|
+
<div className="app-content-grid">
|
|
93
|
+
<section className="app-panel">
|
|
94
|
+
<div className="app-panel-header">
|
|
95
|
+
<h2 className="app-panel-title">Recent activity</h2>
|
|
96
|
+
<span className="app-review-meta">View all</span>
|
|
97
|
+
</div>
|
|
98
|
+
<table className="app-table">
|
|
99
|
+
<thead>
|
|
100
|
+
<tr>
|
|
101
|
+
<th>Item</th>
|
|
102
|
+
<th>Owner</th>
|
|
103
|
+
<th>Status</th>
|
|
104
|
+
<th>Updated</th>
|
|
105
|
+
</tr>
|
|
106
|
+
</thead>
|
|
107
|
+
<tbody>
|
|
108
|
+
{activity.map((row) => (
|
|
109
|
+
<tr key={row.item}>
|
|
110
|
+
<td>{row.item}</td>
|
|
111
|
+
<td className="app-table-muted">{row.owner}</td>
|
|
112
|
+
<td>
|
|
113
|
+
<span className="app-status">{row.status}</span>
|
|
114
|
+
</td>
|
|
115
|
+
<td className="app-table-muted">{row.updated}</td>
|
|
116
|
+
</tr>
|
|
117
|
+
))}
|
|
118
|
+
</tbody>
|
|
119
|
+
</table>
|
|
120
|
+
</section>
|
|
121
|
+
|
|
122
|
+
<div className="grid gap-5">
|
|
123
|
+
<section className="app-panel">
|
|
124
|
+
<div className="app-panel-header">
|
|
125
|
+
<h2 className="app-panel-title">Needs your review</h2>
|
|
126
|
+
</div>
|
|
127
|
+
<div className="app-review-list">
|
|
128
|
+
{reviews.map((review) => (
|
|
129
|
+
<div className="app-review-item" key={review.title}>
|
|
130
|
+
<div>
|
|
131
|
+
<div className="app-review-title">{review.title}</div>
|
|
132
|
+
<div className="app-review-meta">{review.due}</div>
|
|
133
|
+
</div>
|
|
134
|
+
<span className="app-review-action">Review</span>
|
|
135
|
+
</div>
|
|
136
|
+
))}
|
|
137
|
+
</div>
|
|
138
|
+
</section>
|
|
139
|
+
|
|
140
|
+
<section className="app-panel">
|
|
141
|
+
<div className="app-panel-header">
|
|
142
|
+
<h2 className="app-panel-title">On call today</h2>
|
|
143
|
+
</div>
|
|
144
|
+
<div className="app-oncall-list">
|
|
145
|
+
<div className="app-avatar-stack" aria-label="On call users">
|
|
146
|
+
<span>MK</span>
|
|
147
|
+
<span>DA</span>
|
|
148
|
+
<span>PS</span>
|
|
149
|
+
<span>AL</span>
|
|
150
|
+
<span>JR</span>
|
|
151
|
+
</div>
|
|
152
|
+
<p className="app-review-meta">4 more teammates available</p>
|
|
153
|
+
</div>
|
|
154
|
+
</section>
|
|
155
|
+
</div>
|
|
156
|
+
</div>
|
|
157
|
+
</section>
|
|
17
158
|
</div>
|
|
18
159
|
);
|
|
19
160
|
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { zodResolver } from "@hookform/resolvers/zod";
|
|
4
4
|
import { Button, Input } from "@percepta/design";
|
|
5
|
+
import { ArrowRight } from "lucide-react";
|
|
5
6
|
import Link from "next/link";
|
|
6
7
|
import { useRouter, useSearchParams } from "next/navigation";
|
|
7
8
|
import React, { useCallback } from "react";
|
|
@@ -64,26 +65,34 @@ export function CredentialsSignInForm() {
|
|
|
64
65
|
);
|
|
65
66
|
|
|
66
67
|
return (
|
|
67
|
-
<div className="
|
|
68
|
-
<div className="
|
|
69
|
-
<
|
|
70
|
-
|
|
68
|
+
<div className="grid gap-8">
|
|
69
|
+
<div className="grid gap-3">
|
|
70
|
+
<p className="app-auth-kicker">
|
|
71
|
+
<span>01</span>
|
|
72
|
+
<span>Sign in</span>
|
|
73
|
+
</p>
|
|
74
|
+
<h1 className="app-auth-title">Welcome back.</h1>
|
|
75
|
+
<p className="app-auth-copy text-sm">
|
|
71
76
|
Need an account?{" "}
|
|
72
77
|
<Link
|
|
73
|
-
className="
|
|
78
|
+
className="app-auth-link"
|
|
74
79
|
href={`/auth/signup?callbackUrl=${encodeURIComponent(callbackUrl)}`}
|
|
75
80
|
>
|
|
76
81
|
Create one
|
|
77
82
|
</Link>
|
|
78
83
|
</p>
|
|
79
84
|
</div>
|
|
80
|
-
<form className="
|
|
81
|
-
<div className="
|
|
85
|
+
<form className="app-auth-form" onSubmit={handleSubmit(submit)}>
|
|
86
|
+
<div className="app-auth-fields">
|
|
82
87
|
<Controller
|
|
83
88
|
control={control}
|
|
84
89
|
name="email"
|
|
85
90
|
render={({ field, fieldState }) => (
|
|
86
|
-
<FormItem
|
|
91
|
+
<FormItem
|
|
92
|
+
className="app-auth-field"
|
|
93
|
+
label="Email"
|
|
94
|
+
fieldState={fieldState}
|
|
95
|
+
>
|
|
87
96
|
<Input {...field} type="email" autoComplete="email" />
|
|
88
97
|
</FormItem>
|
|
89
98
|
)}
|
|
@@ -92,7 +101,11 @@ export function CredentialsSignInForm() {
|
|
|
92
101
|
control={control}
|
|
93
102
|
name="password"
|
|
94
103
|
render={({ field, fieldState }) => (
|
|
95
|
-
<FormItem
|
|
104
|
+
<FormItem
|
|
105
|
+
className="app-auth-field"
|
|
106
|
+
label="Password"
|
|
107
|
+
fieldState={fieldState}
|
|
108
|
+
>
|
|
96
109
|
<Input
|
|
97
110
|
{...field}
|
|
98
111
|
type="password"
|
|
@@ -103,8 +116,13 @@ export function CredentialsSignInForm() {
|
|
|
103
116
|
/>
|
|
104
117
|
</div>
|
|
105
118
|
<div className="flex justify-end">
|
|
106
|
-
<Button
|
|
107
|
-
|
|
119
|
+
<Button
|
|
120
|
+
className="app-auth-submit"
|
|
121
|
+
type="submit"
|
|
122
|
+
loading={isSubmitting}
|
|
123
|
+
>
|
|
124
|
+
<span>Sign In</span>
|
|
125
|
+
<ArrowRight aria-hidden={true} />
|
|
108
126
|
</Button>
|
|
109
127
|
</div>
|
|
110
128
|
</form>
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { zodResolver } from "@hookform/resolvers/zod";
|
|
4
4
|
import { Button, Input } from "@percepta/design";
|
|
5
|
+
import { ArrowRight } from "lucide-react";
|
|
5
6
|
import Link from "next/link";
|
|
6
7
|
import { useRouter, useSearchParams } from "next/navigation";
|
|
7
8
|
import React, { useCallback } from "react";
|
|
@@ -59,26 +60,34 @@ export function CredentialsSignUpForm() {
|
|
|
59
60
|
);
|
|
60
61
|
|
|
61
62
|
return (
|
|
62
|
-
<div className="
|
|
63
|
-
<div className="
|
|
64
|
-
<
|
|
65
|
-
|
|
63
|
+
<div className="grid gap-8">
|
|
64
|
+
<div className="grid gap-3">
|
|
65
|
+
<p className="app-auth-kicker">
|
|
66
|
+
<span>01</span>
|
|
67
|
+
<span>Request access</span>
|
|
68
|
+
</p>
|
|
69
|
+
<h1 className="app-auth-title">Create account.</h1>
|
|
70
|
+
<p className="app-auth-copy text-sm">
|
|
66
71
|
Already have an account?{" "}
|
|
67
72
|
<Link
|
|
68
|
-
className="
|
|
73
|
+
className="app-auth-link"
|
|
69
74
|
href={`/auth/signin?callbackUrl=${encodeURIComponent(callbackUrl)}`}
|
|
70
75
|
>
|
|
71
76
|
Sign in
|
|
72
77
|
</Link>
|
|
73
78
|
</p>
|
|
74
79
|
</div>
|
|
75
|
-
<form className="
|
|
76
|
-
<div className="
|
|
80
|
+
<form className="app-auth-form" onSubmit={handleSubmit(submit)}>
|
|
81
|
+
<div className="app-auth-fields">
|
|
77
82
|
<Controller
|
|
78
83
|
control={control}
|
|
79
84
|
name="name"
|
|
80
85
|
render={({ field, fieldState }) => (
|
|
81
|
-
<FormItem
|
|
86
|
+
<FormItem
|
|
87
|
+
className="app-auth-field"
|
|
88
|
+
label="Name"
|
|
89
|
+
fieldState={fieldState}
|
|
90
|
+
>
|
|
82
91
|
<Input {...field} autoComplete="name" />
|
|
83
92
|
</FormItem>
|
|
84
93
|
)}
|
|
@@ -87,7 +96,11 @@ export function CredentialsSignUpForm() {
|
|
|
87
96
|
control={control}
|
|
88
97
|
name="email"
|
|
89
98
|
render={({ field, fieldState }) => (
|
|
90
|
-
<FormItem
|
|
99
|
+
<FormItem
|
|
100
|
+
className="app-auth-field"
|
|
101
|
+
label="Email"
|
|
102
|
+
fieldState={fieldState}
|
|
103
|
+
>
|
|
91
104
|
<Input {...field} type="email" autoComplete="email" />
|
|
92
105
|
</FormItem>
|
|
93
106
|
)}
|
|
@@ -96,15 +109,24 @@ export function CredentialsSignUpForm() {
|
|
|
96
109
|
control={control}
|
|
97
110
|
name="password"
|
|
98
111
|
render={({ field, fieldState }) => (
|
|
99
|
-
<FormItem
|
|
112
|
+
<FormItem
|
|
113
|
+
className="app-auth-field"
|
|
114
|
+
label="Password"
|
|
115
|
+
fieldState={fieldState}
|
|
116
|
+
>
|
|
100
117
|
<Input {...field} type="password" autoComplete="new-password" />
|
|
101
118
|
</FormItem>
|
|
102
119
|
)}
|
|
103
120
|
/>
|
|
104
121
|
</div>
|
|
105
122
|
<div className="flex justify-end">
|
|
106
|
-
<Button
|
|
107
|
-
|
|
123
|
+
<Button
|
|
124
|
+
className="app-auth-submit"
|
|
125
|
+
type="submit"
|
|
126
|
+
loading={isSubmitting}
|
|
127
|
+
>
|
|
128
|
+
<span>Create Account</span>
|
|
129
|
+
<ArrowRight aria-hidden={true} />
|
|
108
130
|
</Button>
|
|
109
131
|
</div>
|
|
110
132
|
</form>
|
|
@@ -5,11 +5,37 @@ export default function AuthLayout({
|
|
|
5
5
|
}: {
|
|
6
6
|
children: React.ReactNode;
|
|
7
7
|
}) {
|
|
8
|
+
const appTitle = "__APP_TITLE__";
|
|
9
|
+
const appInitial = appTitle.slice(0, 1).toUpperCase();
|
|
10
|
+
|
|
8
11
|
return (
|
|
9
|
-
<div className="
|
|
10
|
-
<
|
|
11
|
-
|
|
12
|
-
|
|
12
|
+
<div className="app-auth-shell">
|
|
13
|
+
<section className="app-auth-story" aria-label={`${appTitle} starter`}>
|
|
14
|
+
<div className="app-auth-brand">
|
|
15
|
+
<span className="app-auth-mark" aria-hidden={true}>
|
|
16
|
+
{appInitial}
|
|
17
|
+
</span>
|
|
18
|
+
<span>{appTitle}</span>
|
|
19
|
+
</div>
|
|
20
|
+
|
|
21
|
+
<div className="grid max-w-xl gap-6">
|
|
22
|
+
<p className="app-auth-kicker">The workspace</p>
|
|
23
|
+
<h2 className="app-auth-title">
|
|
24
|
+
A quieter place to do your team's most considered work.
|
|
25
|
+
</h2>
|
|
26
|
+
<p className="app-auth-copy">
|
|
27
|
+
Keep decisions, handoffs, and daily operations in one calm
|
|
28
|
+
workspace built for focused teams.
|
|
29
|
+
</p>
|
|
30
|
+
</div>
|
|
31
|
+
|
|
32
|
+
<div className="flex justify-between gap-4 text-xs uppercase text-muted-foreground">
|
|
33
|
+
<span>Workspace 1.0</span>
|
|
34
|
+
<span>Made for teams</span>
|
|
35
|
+
</div>
|
|
36
|
+
</section>
|
|
37
|
+
|
|
38
|
+
<div className="app-auth-card">{children}</div>
|
|
13
39
|
</div>
|
|
14
40
|
);
|
|
15
41
|
}
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
canManageDefaultRoles,
|
|
8
8
|
} from "../../services/access/AppAccessControl";
|
|
9
9
|
|
|
10
|
-
export default async function
|
|
10
|
+
export default async function SettingsLayout({
|
|
11
11
|
children,
|
|
12
12
|
}: {
|
|
13
13
|
children: ReactNode;
|
|
@@ -22,21 +22,17 @@ export default async function AdminLayout({
|
|
|
22
22
|
canManageDefaultRoles(session.user.id),
|
|
23
23
|
canManageAppRoles(session.user.id),
|
|
24
24
|
]);
|
|
25
|
-
const
|
|
25
|
+
const showSettingsLink = canManageDefaults || canManageRoles;
|
|
26
26
|
|
|
27
|
-
if (!
|
|
27
|
+
if (!showSettingsLink) {
|
|
28
28
|
notFound();
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
return (
|
|
32
32
|
<>
|
|
33
|
-
<Header
|
|
34
|
-
<main>
|
|
35
|
-
<div className="
|
|
36
|
-
<div className="mx-auto max-w-6xl">
|
|
37
|
-
<div className="rounded-lg bg-white p-8">{children}</div>
|
|
38
|
-
</div>
|
|
39
|
-
</div>
|
|
33
|
+
<Header showSettingsLink={showSettingsLink} />
|
|
34
|
+
<main className="app-shell-main">
|
|
35
|
+
<div className="app-home">{children}</div>
|
|
40
36
|
</main>
|
|
41
37
|
</>
|
|
42
38
|
);
|
|
@@ -3,25 +3,29 @@
|
|
|
3
3
|
import { Tabs, TabsList, TabsTrigger } from "@percepta/design";
|
|
4
4
|
import Link from "next/link";
|
|
5
5
|
|
|
6
|
-
export type
|
|
6
|
+
export type AccessControlTab = "assignments" | "roles";
|
|
7
7
|
|
|
8
8
|
const tabs = [
|
|
9
|
-
{ href: "/
|
|
9
|
+
{ href: "/settings?tab=roles", label: "Roles", tab: "roles" },
|
|
10
10
|
{
|
|
11
|
-
href: "/
|
|
11
|
+
href: "/settings?tab=assignments",
|
|
12
12
|
label: "Assignments",
|
|
13
13
|
tab: "assignments",
|
|
14
14
|
},
|
|
15
15
|
] as const satisfies ReadonlyArray<{
|
|
16
16
|
readonly href: string;
|
|
17
17
|
readonly label: string;
|
|
18
|
-
readonly tab:
|
|
18
|
+
readonly tab: AccessControlTab;
|
|
19
19
|
}>;
|
|
20
20
|
|
|
21
|
-
export function
|
|
21
|
+
export function AccessControlTabs({
|
|
22
|
+
activeTab,
|
|
23
|
+
}: {
|
|
24
|
+
readonly activeTab: AccessControlTab;
|
|
25
|
+
}) {
|
|
22
26
|
return (
|
|
23
27
|
<Tabs value={activeTab}>
|
|
24
|
-
<TabsList aria-label="
|
|
28
|
+
<TabsList aria-label="Access control views">
|
|
25
29
|
{tabs.map((tab) => (
|
|
26
30
|
<TabsTrigger asChild={true} key={tab.tab} value={tab.tab}>
|
|
27
31
|
<Link href={tab.href}>{tab.label}</Link>
|
|
@@ -12,13 +12,13 @@ export type AccessAppRole = AppRole<typeof accessManifest>;
|
|
|
12
12
|
export const assignableRoles: readonly AccessAppRole[] =
|
|
13
13
|
accessManifest.system.assignableRoles ?? [];
|
|
14
14
|
|
|
15
|
-
export interface
|
|
15
|
+
export interface SettingsPermissions {
|
|
16
16
|
readonly canManageAppRoles: boolean;
|
|
17
17
|
readonly canManageDefaultRoles: boolean;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
export async function
|
|
21
|
-
if (!
|
|
20
|
+
export async function requireAnySettingsPermission(): Promise<SettingsPermissions> {
|
|
21
|
+
if (!isAccessControlUiEnabled()) {
|
|
22
22
|
notFound();
|
|
23
23
|
}
|
|
24
24
|
|
|
@@ -55,8 +55,8 @@ export function readAssignableRole(formData: FormData): AccessAppRole {
|
|
|
55
55
|
return role as AccessAppRole;
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
function
|
|
59
|
-
const
|
|
58
|
+
function isAccessControlUiEnabled(): boolean {
|
|
59
|
+
const accessControlUI: { readonly enabled?: boolean } | undefined =
|
|
60
60
|
accessManifest.adminUI;
|
|
61
|
-
return
|
|
61
|
+
return accessControlUI?.enabled !== false;
|
|
62
62
|
}
|