@percepta/create 3.1.5 → 3.3.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 +53 -46
- package/dist/index.js.map +1 -1
- package/dist/{init-OeK4Yk6_.js → init-CtCp7Tv2.js} +3 -3
- package/dist/init-CtCp7Tv2.js.map +1 -0
- package/dist/{status-DC8mvHZj.js → status-CKe4aKso.js} +2 -2
- package/dist/{status-DC8mvHZj.js.map → status-CKe4aKso.js.map} +1 -1
- package/dist/{sync-C5Pd32VM.js → sync-D1vkoofl.js} +2 -2
- package/dist/{sync-C5Pd32VM.js.map → sync-D1vkoofl.js.map} +1 -1
- package/dist/{upstream-F6m8zRBQ.js → upstream-D-LH_1z4.js} +2 -2
- package/dist/{upstream-F6m8zRBQ.js.map → upstream-D-LH_1z4.js.map} +1 -1
- package/package.json +2 -2
- package/template-versions.json +1 -1
- package/templates/monorepo/.github/workflows/access-control.yml +38 -0
- package/templates/monorepo/README.md +42 -2
- package/templates/monorepo/access/README.md +39 -0
- package/templates/monorepo/access/bootstrap-grants.yaml.example +9 -0
- package/templates/monorepo/access/dev-grants.yaml.example +19 -0
- package/templates/monorepo/access/dev-groups.yaml.example +8 -0
- package/templates/monorepo/access/reconcile.yaml.example +11 -0
- package/templates/monorepo/auth/README.md +27 -0
- package/templates/monorepo/auth/drizzle.config.ts +13 -0
- package/templates/monorepo/auth/package.json +29 -0
- package/templates/monorepo/auth/scripts/setup-database.ts +11 -0
- package/templates/monorepo/auth/src/auth.ts +47 -0
- package/templates/monorepo/auth/src/config/database.ts +15 -0
- package/templates/monorepo/auth/src/drizzle/db.ts +8 -0
- package/templates/monorepo/auth/src/drizzle/migrations/0000_shared_auth.sql +89 -0
- package/templates/monorepo/auth/src/drizzle/migrations/meta/_journal.json +13 -0
- package/templates/monorepo/auth/src/drizzle/schema/auth/accounts.ts +7 -0
- package/templates/monorepo/auth/src/drizzle/schema/auth/sessions.ts +7 -0
- package/templates/monorepo/auth/src/drizzle/schema/auth/verifications.ts +6 -0
- package/templates/monorepo/auth/src/drizzle/schema/groups.ts +16 -0
- package/templates/monorepo/auth/src/drizzle/schema/index.ts +5 -0
- package/templates/monorepo/auth/src/drizzle/schema/users.ts +6 -0
- package/templates/monorepo/auth/src/index.ts +1 -0
- package/templates/monorepo/auth/src/scim/README.md +6 -0
- package/templates/monorepo/auth/tsconfig.json +12 -0
- package/templates/monorepo/package.json.template +18 -6
- package/templates/monorepo/pnpm-workspace.yaml +1 -0
- package/templates/webapp/AGENTS.md +13 -6
- package/templates/webapp/README.md +34 -18
- package/templates/webapp/agent-skills/access-control.md +301 -0
- package/templates/webapp/agent-skills/database.md +1 -1
- package/templates/webapp/docker-compose.yml +16 -0
- package/templates/webapp/env.example.template +9 -0
- package/templates/webapp/next.config.ts +1 -0
- package/templates/webapp/package.json.template +8 -4
- package/templates/webapp/scripts/seed.ts +87 -36
- package/templates/webapp/scripts/setup-database.ts +7 -1
- package/templates/webapp/scripts/start.sh +0 -9
- package/templates/webapp/src/access/access.manifest.ts +15 -0
- package/templates/webapp/src/access/schema.zed +7 -0
- package/templates/webapp/src/app/(app)/admin/_lib/PrincipalRoleTable.tsx +113 -0
- package/templates/webapp/src/app/(app)/admin/_lib/accessAdmin.ts +85 -0
- package/templates/webapp/src/app/(app)/admin/groups/page.tsx +117 -0
- package/templates/webapp/src/app/(app)/admin/users/page.tsx +79 -0
- package/templates/webapp/src/app/(app)/layout.tsx +16 -2
- package/templates/webapp/src/app/(app)/page.tsx +1 -12
- package/templates/webapp/src/app/(auth)/auth/signin/page.tsx +2 -5
- package/templates/webapp/src/app/(auth)/auth/signup/page.tsx +2 -5
- package/templates/webapp/src/config/getEnvConfig.ts +8 -0
- package/templates/webapp/src/drizzle/db.ts +3 -4
- package/templates/webapp/src/drizzle/migrations/0000_eager_grandmaster.sql +1 -57
- package/templates/webapp/src/drizzle/migrations/meta/0000_snapshot.json +1 -347
- package/templates/webapp/src/drizzle/schema/index.ts +3 -4
- package/templates/webapp/src/lib/auth/index.ts +6 -81
- package/templates/webapp/src/server/api/root.ts +4 -1
- package/templates/webapp/src/server/api/routers/access.ts +13 -0
- package/templates/webapp/src/server/trpc.ts +42 -8
- package/templates/webapp/src/services/DatabaseService.ts +4 -5
- package/templates/webapp/src/services/access/AppAccessControl.ts +39 -0
- package/dist/init-OeK4Yk6_.js.map +0 -1
- package/templates/webapp/scripts/create-user.ts +0 -47
- package/templates/webapp/src/drizzle/schema/auth/accounts.ts +0 -33
- package/templates/webapp/src/drizzle/schema/auth/sessions.ts +0 -25
- package/templates/webapp/src/drizzle/schema/auth/users.ts +0 -38
- package/templates/webapp/src/drizzle/schema/auth/verifications.ts +0 -19
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Development seed script.
|
|
5
|
-
* Creates
|
|
5
|
+
* Creates shared auth users and local access grants for development.
|
|
6
6
|
*
|
|
7
7
|
* Usage: pnpm db:seed
|
|
8
8
|
*/
|
|
@@ -10,52 +10,103 @@
|
|
|
10
10
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
11
11
|
import { loadEnvConfig } from "@next/env";
|
|
12
12
|
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
13
|
+
const SEEDED_USERS = [
|
|
14
|
+
{
|
|
15
|
+
access: "owner",
|
|
16
|
+
appRole: "admin",
|
|
17
|
+
email: "admin@example.com",
|
|
18
|
+
name: "Admin User",
|
|
19
|
+
password: "password",
|
|
20
|
+
role: "admin",
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
access: "member",
|
|
24
|
+
email: "user@example.com",
|
|
25
|
+
name: "Regular User",
|
|
26
|
+
password: "password",
|
|
27
|
+
role: "user",
|
|
28
|
+
},
|
|
29
|
+
] as const;
|
|
18
30
|
|
|
19
31
|
async function main(): Promise<void> {
|
|
20
32
|
loadEnvConfig(process.cwd());
|
|
21
33
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
|
|
22
34
|
(globalThis as any).AsyncLocalStorage = AsyncLocalStorage;
|
|
23
35
|
|
|
24
|
-
const { auth } = await import("
|
|
25
|
-
const { db } = await import("
|
|
26
|
-
const { users } = await import("
|
|
36
|
+
const { auth } = await import("@__REPO_NAME__/auth");
|
|
37
|
+
const { db: authDb } = await import("@__REPO_NAME__/auth/db");
|
|
38
|
+
const { users } = await import("@__REPO_NAME__/auth/schema");
|
|
39
|
+
const { getAccessControl, toUserSubject } =
|
|
40
|
+
await import("../src/services/access/AppAccessControl");
|
|
41
|
+
const { createCustomerAccessControl } =
|
|
42
|
+
await import("@percepta/access-control");
|
|
27
43
|
const { eq, sql } = await import("drizzle-orm");
|
|
28
44
|
|
|
29
|
-
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
.
|
|
33
|
-
.where(eq(sql`lower(${users.email})`, sql`lower(${DEFAULT_USER.email})`))
|
|
34
|
-
.limit(1);
|
|
35
|
-
|
|
36
|
-
if (existing != null) {
|
|
37
|
-
await db
|
|
38
|
-
.update(users)
|
|
39
|
-
.set({ role: "admin" })
|
|
40
|
-
.where(eq(users.id, existing.id));
|
|
41
|
-
console.log(
|
|
42
|
-
`Seed user "${DEFAULT_USER.email}" already exists (id: ${existing.id}). Ensured admin role.`,
|
|
43
|
-
);
|
|
44
|
-
process.exit(0);
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
// Use Better Auth's signUpEmail API to create the user with a hashed password
|
|
48
|
-
const res = await auth.api.signUpEmail({
|
|
49
|
-
body: DEFAULT_USER,
|
|
45
|
+
const access = getAccessControl();
|
|
46
|
+
const customerAccess = createCustomerAccessControl({
|
|
47
|
+
applications: [{ appNamespace: access.manifest.appNamespace }],
|
|
48
|
+
client: access.client,
|
|
50
49
|
});
|
|
51
50
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
51
|
+
for (const seededUser of SEEDED_USERS) {
|
|
52
|
+
// Check if user already exists
|
|
53
|
+
const [existing] = await authDb
|
|
54
|
+
.select({ id: users.id })
|
|
55
|
+
.from(users)
|
|
56
|
+
.where(eq(sql`lower(${users.email})`, sql`lower(${seededUser.email})`))
|
|
57
|
+
.limit(1);
|
|
58
|
+
|
|
59
|
+
let userId: string;
|
|
60
|
+
if (existing != null) {
|
|
61
|
+
await authDb
|
|
62
|
+
.update(users)
|
|
63
|
+
.set({ role: seededUser.role })
|
|
64
|
+
.where(eq(users.id, existing.id));
|
|
65
|
+
userId = existing.id;
|
|
66
|
+
console.log(
|
|
67
|
+
`Seed user "${seededUser.email}" already exists (id: ${existing.id}). Ensured ${seededUser.role} role.`,
|
|
68
|
+
);
|
|
69
|
+
} else {
|
|
70
|
+
// Use Better Auth's signUpEmail API to create the user with a hashed password
|
|
71
|
+
const res = await auth.api.signUpEmail({
|
|
72
|
+
body: {
|
|
73
|
+
email: seededUser.email,
|
|
74
|
+
name: seededUser.name,
|
|
75
|
+
password: seededUser.password,
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
await authDb
|
|
80
|
+
.update(users)
|
|
81
|
+
.set({ role: seededUser.role })
|
|
82
|
+
.where(eq(users.id, res.user.id));
|
|
83
|
+
|
|
84
|
+
userId = res.user.id;
|
|
85
|
+
console.log(
|
|
86
|
+
`Seed user created: ${seededUser.email} (id: ${res.user.id})`,
|
|
87
|
+
);
|
|
88
|
+
console.log(` Password: ${seededUser.password}`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const subject = toUserSubject(userId);
|
|
92
|
+
if (seededUser.access === "owner") {
|
|
93
|
+
await customerAccess.assignApplicationOwner(
|
|
94
|
+
access.manifest.appNamespace,
|
|
95
|
+
subject,
|
|
96
|
+
);
|
|
97
|
+
} else {
|
|
98
|
+
await customerAccess.assignApplicationMember(
|
|
99
|
+
access.manifest.appNamespace,
|
|
100
|
+
subject,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if ("appRole" in seededUser) {
|
|
105
|
+
await access.app.assignAppRole(seededUser.appRole, subject);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
56
108
|
|
|
57
|
-
console.log(
|
|
58
|
-
console.log(` Password: ${DEFAULT_USER.password}`);
|
|
109
|
+
console.log("Ensured local app access grants.");
|
|
59
110
|
process.exit(0);
|
|
60
111
|
}
|
|
61
112
|
|
|
@@ -53,7 +53,9 @@ async function main(): Promise<void> {
|
|
|
53
53
|
if (result.rows.length === 0) {
|
|
54
54
|
console.log(`📦 Creating database: ${database}`);
|
|
55
55
|
// Create the database (note: database names cannot be parameterized)
|
|
56
|
-
await adminClient.query(
|
|
56
|
+
await adminClient.query(
|
|
57
|
+
`CREATE DATABASE ${escapePgIdentifier(database)}`,
|
|
58
|
+
);
|
|
57
59
|
console.log(`✅ Database ${database} created successfully`);
|
|
58
60
|
} else {
|
|
59
61
|
console.log(`✅ Database ${database} already exists`);
|
|
@@ -66,6 +68,10 @@ async function main(): Promise<void> {
|
|
|
66
68
|
}
|
|
67
69
|
}
|
|
68
70
|
|
|
71
|
+
function escapePgIdentifier(identifier: string): string {
|
|
72
|
+
return `"${identifier.replaceAll('"', '""')}"`;
|
|
73
|
+
}
|
|
74
|
+
|
|
69
75
|
void main().catch((error) => {
|
|
70
76
|
console.error("💥 Database setup failed:", error);
|
|
71
77
|
process.exit(1);
|
|
@@ -39,15 +39,6 @@ else
|
|
|
39
39
|
echo "ℹ️ READONLY_SECRET_NAME not set, skipping readonly user setup"
|
|
40
40
|
fi
|
|
41
41
|
|
|
42
|
-
if [ -n "$ADMIN_USERNAME" ] && [ -n "$ADMIN_PASSWORD" ] && [ -n "$ADMIN_NAME" ]; then
|
|
43
|
-
echo "Creating admin user..."
|
|
44
|
-
if pnpm exec tsx scripts/create-user.ts "$ADMIN_USERNAME" "$ADMIN_PASSWORD" --name "$ADMIN_NAME"; then
|
|
45
|
-
echo "✅ Admin user created (or already exists)."
|
|
46
|
-
else
|
|
47
|
-
echo "⚠️ Failed to create admin user."
|
|
48
|
-
fi
|
|
49
|
-
fi
|
|
50
|
-
|
|
51
42
|
# Start the Next.js application
|
|
52
43
|
echo "Starting Next.js server on port ${PORT:-3000}..."
|
|
53
44
|
exec pnpm start
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { defineAccessManifest } from "@percepta/access-control";
|
|
2
|
+
|
|
3
|
+
export const accessManifest = defineAccessManifest({
|
|
4
|
+
adminUI: {
|
|
5
|
+
enabled: true,
|
|
6
|
+
},
|
|
7
|
+
appNamespace: "__APP_NAME_SNAKE__",
|
|
8
|
+
application: {
|
|
9
|
+
displayName: "__APP_TITLE__",
|
|
10
|
+
urlEnv: "APP_BASE_URL",
|
|
11
|
+
},
|
|
12
|
+
system: {
|
|
13
|
+
assignableRoles: ["admin"],
|
|
14
|
+
},
|
|
15
|
+
} as const);
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import type { SubjectRef } from "@percepta/access-control";
|
|
2
|
+
import { ShieldCheck, ShieldMinus } from "lucide-react";
|
|
3
|
+
import { type AccessAppRole, assignableRoles } from "./accessAdmin";
|
|
4
|
+
|
|
5
|
+
type RoleAction = (formData: FormData) => Promise<void>;
|
|
6
|
+
|
|
7
|
+
export interface PrincipalRoleRow {
|
|
8
|
+
readonly accessLabel: string;
|
|
9
|
+
readonly canAssignRoles?: boolean;
|
|
10
|
+
readonly detail: string;
|
|
11
|
+
readonly displayName: string;
|
|
12
|
+
readonly roles: readonly AccessAppRole[];
|
|
13
|
+
readonly subject: SubjectRef;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function PrincipalRoleTable({
|
|
17
|
+
assignAction,
|
|
18
|
+
principalColumnLabel,
|
|
19
|
+
revokeAction,
|
|
20
|
+
rows,
|
|
21
|
+
}: {
|
|
22
|
+
readonly assignAction: RoleAction;
|
|
23
|
+
readonly principalColumnLabel: string;
|
|
24
|
+
readonly revokeAction: RoleAction;
|
|
25
|
+
readonly rows: readonly PrincipalRoleRow[];
|
|
26
|
+
}) {
|
|
27
|
+
return (
|
|
28
|
+
<div className="overflow-x-auto rounded-md border border-border">
|
|
29
|
+
<table className="w-full text-left text-sm">
|
|
30
|
+
<thead className="border-b border-border bg-muted/50 text-xs text-muted-foreground uppercase">
|
|
31
|
+
<tr>
|
|
32
|
+
<th className="px-4 py-3 font-medium">{principalColumnLabel}</th>
|
|
33
|
+
<th className="px-4 py-3 font-medium">Application Access</th>
|
|
34
|
+
<th className="px-4 py-3 font-medium">Roles</th>
|
|
35
|
+
<th className="px-4 py-3 font-medium">Actions</th>
|
|
36
|
+
</tr>
|
|
37
|
+
</thead>
|
|
38
|
+
<tbody className="divide-y divide-border">
|
|
39
|
+
{rows.map((principal) => (
|
|
40
|
+
<tr key={principal.subject} className="align-top">
|
|
41
|
+
<td className="px-4 py-4">
|
|
42
|
+
<div className="font-medium text-foreground">
|
|
43
|
+
{principal.displayName}
|
|
44
|
+
</div>
|
|
45
|
+
<div className="text-muted-foreground">{principal.detail}</div>
|
|
46
|
+
</td>
|
|
47
|
+
<td className="px-4 py-4 text-foreground">
|
|
48
|
+
{principal.accessLabel}
|
|
49
|
+
</td>
|
|
50
|
+
<td className="px-4 py-4">
|
|
51
|
+
{principal.roles.length > 0 ? (
|
|
52
|
+
<div className="flex flex-wrap gap-2">
|
|
53
|
+
{principal.roles.map((role) => (
|
|
54
|
+
<span
|
|
55
|
+
key={role}
|
|
56
|
+
className="rounded-md border border-border bg-muted px-2 py-1 text-xs font-medium text-foreground"
|
|
57
|
+
>
|
|
58
|
+
{role}
|
|
59
|
+
</span>
|
|
60
|
+
))}
|
|
61
|
+
</div>
|
|
62
|
+
) : (
|
|
63
|
+
<span className="text-muted-foreground">None</span>
|
|
64
|
+
)}
|
|
65
|
+
</td>
|
|
66
|
+
<td className="px-4 py-4">
|
|
67
|
+
<div className="flex flex-wrap gap-2">
|
|
68
|
+
{assignableRoles.map((role) => {
|
|
69
|
+
const hasRole = principal.roles.includes(role);
|
|
70
|
+
const action = hasRole ? revokeAction : assignAction;
|
|
71
|
+
return (
|
|
72
|
+
<form action={action} key={role}>
|
|
73
|
+
<input
|
|
74
|
+
name="subject"
|
|
75
|
+
type="hidden"
|
|
76
|
+
value={principal.subject}
|
|
77
|
+
/>
|
|
78
|
+
<input name="role" type="hidden" value={role} />
|
|
79
|
+
<button
|
|
80
|
+
className={
|
|
81
|
+
hasRole
|
|
82
|
+
? "inline-flex h-9 items-center gap-2 rounded-md border border-border px-3 text-sm font-medium text-foreground transition-colors hover:bg-muted"
|
|
83
|
+
: "inline-flex h-9 items-center gap-2 rounded-md bg-primary px-3 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50"
|
|
84
|
+
}
|
|
85
|
+
disabled={
|
|
86
|
+
!hasRole && principal.canAssignRoles === false
|
|
87
|
+
}
|
|
88
|
+
type="submit"
|
|
89
|
+
>
|
|
90
|
+
{hasRole ? (
|
|
91
|
+
<>
|
|
92
|
+
<ShieldMinus className="h-4 w-4" />
|
|
93
|
+
Revoke {role}
|
|
94
|
+
</>
|
|
95
|
+
) : (
|
|
96
|
+
<>
|
|
97
|
+
<ShieldCheck className="h-4 w-4" />
|
|
98
|
+
Assign {role}
|
|
99
|
+
</>
|
|
100
|
+
)}
|
|
101
|
+
</button>
|
|
102
|
+
</form>
|
|
103
|
+
);
|
|
104
|
+
})}
|
|
105
|
+
</div>
|
|
106
|
+
</td>
|
|
107
|
+
</tr>
|
|
108
|
+
))}
|
|
109
|
+
</tbody>
|
|
110
|
+
</table>
|
|
111
|
+
</div>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { AppRole, SubjectRef } from "@percepta/access-control";
|
|
2
|
+
import { revalidatePath } from "next/cache";
|
|
3
|
+
import { notFound, redirect } from "next/navigation";
|
|
4
|
+
import { accessManifest } from "../../../../access/access.manifest";
|
|
5
|
+
import { getServerSession } from "../../../../lib/auth";
|
|
6
|
+
import {
|
|
7
|
+
getAccessControl,
|
|
8
|
+
toUserSubject,
|
|
9
|
+
} from "../../../../services/access/AppAccessControl";
|
|
10
|
+
|
|
11
|
+
export type AccessAppRole = AppRole<typeof accessManifest>;
|
|
12
|
+
|
|
13
|
+
export const assignableRoles: readonly AccessAppRole[] =
|
|
14
|
+
accessManifest.system.assignableRoles ?? [];
|
|
15
|
+
|
|
16
|
+
export async function updatePrincipalRole(
|
|
17
|
+
formData: FormData,
|
|
18
|
+
operation: "assign" | "revoke",
|
|
19
|
+
path: string,
|
|
20
|
+
) {
|
|
21
|
+
await requireRoleManager();
|
|
22
|
+
const app = getAccessControl().app;
|
|
23
|
+
const role = readAssignableRole(formData);
|
|
24
|
+
const subject = readPrincipalSubject(formData);
|
|
25
|
+
|
|
26
|
+
if (operation === "assign") {
|
|
27
|
+
await app.assignAppRole(role, subject);
|
|
28
|
+
} else {
|
|
29
|
+
await app.revokeAppRole(role, subject);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
revalidatePath(path);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function requireRoleManager() {
|
|
36
|
+
if (!isAdminUiEnabled()) {
|
|
37
|
+
notFound();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const session = await getServerSession();
|
|
41
|
+
|
|
42
|
+
if (session?.user == null) {
|
|
43
|
+
redirect("/auth/signin");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const allowed = await getAccessControl().permissions.canStrong({
|
|
47
|
+
permission: accessManifest.system.manageRolesPermission,
|
|
48
|
+
resource: accessManifest.system.ref(),
|
|
49
|
+
subject: toUserSubject(session.user.id),
|
|
50
|
+
});
|
|
51
|
+
if (!allowed) {
|
|
52
|
+
notFound();
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function readAssignableRole(formData: FormData): AccessAppRole {
|
|
57
|
+
const role = formData.get("role");
|
|
58
|
+
if (
|
|
59
|
+
typeof role !== "string" ||
|
|
60
|
+
!assignableRoles.includes(role as AccessAppRole)
|
|
61
|
+
) {
|
|
62
|
+
throw new Error("Invalid app role.");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return role as AccessAppRole;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function readPrincipalSubject(formData: FormData): SubjectRef {
|
|
69
|
+
const subject = formData.get("subject");
|
|
70
|
+
if (
|
|
71
|
+
typeof subject !== "string" ||
|
|
72
|
+
(!subject.startsWith("core/user:") &&
|
|
73
|
+
!/^core\/group:[^#]+#member$/.test(subject))
|
|
74
|
+
) {
|
|
75
|
+
throw new Error("Invalid principal subject.");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return subject as SubjectRef;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function isAdminUiEnabled(): boolean {
|
|
82
|
+
const adminUI: { readonly enabled?: boolean } | undefined =
|
|
83
|
+
accessManifest.adminUI;
|
|
84
|
+
return adminUI?.enabled !== false;
|
|
85
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import type { SubjectRef } from "@percepta/access-control";
|
|
2
|
+
import { db as authDb } from "@__REPO_NAME__/auth/db";
|
|
3
|
+
import { groups } from "@__REPO_NAME__/auth/schema";
|
|
4
|
+
import { inArray } from "drizzle-orm";
|
|
5
|
+
import type { Metadata } from "next";
|
|
6
|
+
import { accessManifest } from "../../../../access/access.manifest";
|
|
7
|
+
import { getAccessControl } from "../../../../services/access/AppAccessControl";
|
|
8
|
+
import {
|
|
9
|
+
type PrincipalRoleRow,
|
|
10
|
+
PrincipalRoleTable,
|
|
11
|
+
} from "../_lib/PrincipalRoleTable";
|
|
12
|
+
import { requireRoleManager, updatePrincipalRole } from "../_lib/accessAdmin";
|
|
13
|
+
|
|
14
|
+
export const metadata: Metadata = {
|
|
15
|
+
title: "Groups - __APP_TITLE__",
|
|
16
|
+
description: "Groups - __APP_TITLE__",
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const GROUP_SUBJECT_PATTERN = /^core\/group:([^#]+)#member$/;
|
|
20
|
+
|
|
21
|
+
export default async function AdminGroupsPage() {
|
|
22
|
+
await requireRoleManager();
|
|
23
|
+
|
|
24
|
+
const rows = await listApplicationGroups();
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<div className="space-y-6">
|
|
28
|
+
<div>
|
|
29
|
+
<h1 className="text-2xl font-semibold text-foreground">Groups</h1>
|
|
30
|
+
</div>
|
|
31
|
+
|
|
32
|
+
{rows.length === 0 ? (
|
|
33
|
+
<div className="rounded-md border border-border bg-card p-8 text-sm text-muted-foreground">
|
|
34
|
+
<p className="font-medium text-foreground">No groups enrolled.</p>
|
|
35
|
+
<p className="mt-2">
|
|
36
|
+
Groups appear here after the customer identity sync projects them
|
|
37
|
+
into SpiceDB and grants this application access.
|
|
38
|
+
</p>
|
|
39
|
+
</div>
|
|
40
|
+
) : (
|
|
41
|
+
<PrincipalRoleTable
|
|
42
|
+
assignAction={assignGroupRole}
|
|
43
|
+
principalColumnLabel="Group"
|
|
44
|
+
revokeAction={revokeGroupRole}
|
|
45
|
+
rows={rows}
|
|
46
|
+
/>
|
|
47
|
+
)}
|
|
48
|
+
</div>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function assignGroupRole(formData: FormData) {
|
|
53
|
+
"use server";
|
|
54
|
+
|
|
55
|
+
await updatePrincipalRole(formData, "assign", "/admin/groups");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function revokeGroupRole(formData: FormData) {
|
|
59
|
+
"use server";
|
|
60
|
+
|
|
61
|
+
await updatePrincipalRole(formData, "revoke", "/admin/groups");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function listApplicationGroups(): Promise<readonly PrincipalRoleRow[]> {
|
|
65
|
+
const access = getAccessControl();
|
|
66
|
+
const subjects = await access.permissions.lookupSubjects({
|
|
67
|
+
permission: accessManifest.system.accessPermission,
|
|
68
|
+
resource: accessManifest.system.ref(),
|
|
69
|
+
subjectRelation: "member",
|
|
70
|
+
subjectType: "core/group",
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
if (subjects.length === 0) {
|
|
74
|
+
return [];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const groupRefs = subjects.map((subject) => ({
|
|
78
|
+
id: groupIdFromSubject(subject),
|
|
79
|
+
subject,
|
|
80
|
+
}));
|
|
81
|
+
const groupNames = await listGroupNames(groupRefs.map(({ id }) => id));
|
|
82
|
+
|
|
83
|
+
const rows = await Promise.all(
|
|
84
|
+
groupRefs.map(async ({ id, subject }) => ({
|
|
85
|
+
accessLabel: "Allowed",
|
|
86
|
+
detail: subject,
|
|
87
|
+
displayName: groupNames.get(id) ?? id,
|
|
88
|
+
roles: await access.app.listAppRoles(subject),
|
|
89
|
+
subject,
|
|
90
|
+
})),
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
return rows.sort((a, b) => a.displayName.localeCompare(b.displayName));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function listGroupNames(
|
|
97
|
+
groupIds: readonly string[],
|
|
98
|
+
): Promise<ReadonlyMap<string, string>> {
|
|
99
|
+
const groupRows = await authDb
|
|
100
|
+
.select({
|
|
101
|
+
id: groups.id,
|
|
102
|
+
name: groups.name,
|
|
103
|
+
})
|
|
104
|
+
.from(groups)
|
|
105
|
+
.where(inArray(groups.id, [...new Set(groupIds)]));
|
|
106
|
+
|
|
107
|
+
return new Map(groupRows.map((group) => [group.id, group.name]));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function groupIdFromSubject(subject: SubjectRef): string {
|
|
111
|
+
const id = GROUP_SUBJECT_PATTERN.exec(subject)?.[1];
|
|
112
|
+
if (id == null) {
|
|
113
|
+
throw new Error("Invalid group subject.");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return id;
|
|
117
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { db as authDb } from "@__REPO_NAME__/auth/db";
|
|
2
|
+
import { users } from "@__REPO_NAME__/auth/schema";
|
|
3
|
+
import type { Metadata } from "next";
|
|
4
|
+
import { accessManifest } from "../../../../access/access.manifest";
|
|
5
|
+
import {
|
|
6
|
+
getAccessControl,
|
|
7
|
+
toUserSubject,
|
|
8
|
+
} from "../../../../services/access/AppAccessControl";
|
|
9
|
+
import { PrincipalRoleTable } from "../_lib/PrincipalRoleTable";
|
|
10
|
+
import { requireRoleManager, updatePrincipalRole } from "../_lib/accessAdmin";
|
|
11
|
+
|
|
12
|
+
export const metadata: Metadata = {
|
|
13
|
+
title: "Users - __APP_TITLE__",
|
|
14
|
+
description: "Users - __APP_TITLE__",
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export default async function AdminUsersPage() {
|
|
18
|
+
await requireRoleManager();
|
|
19
|
+
|
|
20
|
+
const access = getAccessControl();
|
|
21
|
+
const userRows = await authDb
|
|
22
|
+
.select({
|
|
23
|
+
email: users.email,
|
|
24
|
+
id: users.id,
|
|
25
|
+
name: users.name,
|
|
26
|
+
})
|
|
27
|
+
.from(users)
|
|
28
|
+
.orderBy(users.email);
|
|
29
|
+
|
|
30
|
+
const rows = await Promise.all(
|
|
31
|
+
userRows.map(async (user) => {
|
|
32
|
+
const subject = toUserSubject(user.id);
|
|
33
|
+
const [canAccessApp, roles] = await Promise.all([
|
|
34
|
+
access.permissions.can({
|
|
35
|
+
permission: accessManifest.system.accessPermission,
|
|
36
|
+
resource: accessManifest.system.ref(),
|
|
37
|
+
subject,
|
|
38
|
+
}),
|
|
39
|
+
access.app.listAppRoles(subject),
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
accessLabel: canAccessApp ? "Allowed" : "Not enrolled",
|
|
44
|
+
canAssignRoles: canAccessApp,
|
|
45
|
+
detail: user.email,
|
|
46
|
+
displayName: user.name,
|
|
47
|
+
roles,
|
|
48
|
+
subject,
|
|
49
|
+
};
|
|
50
|
+
}),
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<div className="space-y-6">
|
|
55
|
+
<div>
|
|
56
|
+
<h1 className="text-2xl font-semibold text-foreground">Users</h1>
|
|
57
|
+
</div>
|
|
58
|
+
|
|
59
|
+
<PrincipalRoleTable
|
|
60
|
+
assignAction={assignUserRole}
|
|
61
|
+
principalColumnLabel="User"
|
|
62
|
+
revokeAction={revokeUserRole}
|
|
63
|
+
rows={rows}
|
|
64
|
+
/>
|
|
65
|
+
</div>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function assignUserRole(formData: FormData) {
|
|
70
|
+
"use server";
|
|
71
|
+
|
|
72
|
+
await updatePrincipalRole(formData, "assign", "/admin/users");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function revokeUserRole(formData: FormData) {
|
|
76
|
+
"use server";
|
|
77
|
+
|
|
78
|
+
await updatePrincipalRole(formData, "revoke", "/admin/users");
|
|
79
|
+
}
|
|
@@ -1,7 +1,21 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { notFound, redirect } from "next/navigation";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
2
3
|
import Header from "../../components/Header";
|
|
4
|
+
import { getServerSession } from "../../lib/auth";
|
|
5
|
+
import { canAccessApplication } from "../../services/access/AppAccessControl";
|
|
6
|
+
|
|
7
|
+
export default async function AppLayout({ children }: { children: ReactNode }) {
|
|
8
|
+
const session = await getServerSession();
|
|
9
|
+
|
|
10
|
+
if (session?.user == null) {
|
|
11
|
+
redirect("/auth/signin");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const canAccessApp = await canAccessApplication(session.user.id);
|
|
15
|
+
if (!canAccessApp) {
|
|
16
|
+
notFound();
|
|
17
|
+
}
|
|
3
18
|
|
|
4
|
-
export default function AppLayout({ children }: { children: React.ReactNode }) {
|
|
5
19
|
return (
|
|
6
20
|
<>
|
|
7
21
|
<Header />
|
|
@@ -1,22 +1,11 @@
|
|
|
1
1
|
import type { Metadata } from "next";
|
|
2
|
-
import { headers } from "next/headers";
|
|
3
|
-
import { redirect } from "next/navigation";
|
|
4
|
-
import { auth } from "../../lib/auth";
|
|
5
2
|
|
|
6
3
|
export const metadata: Metadata = {
|
|
7
4
|
title: "__APP_TITLE__",
|
|
8
5
|
description: "__APP_TITLE__",
|
|
9
6
|
};
|
|
10
7
|
|
|
11
|
-
export default
|
|
12
|
-
const session = await auth.api.getSession({
|
|
13
|
-
headers: await headers(),
|
|
14
|
-
});
|
|
15
|
-
|
|
16
|
-
if (session?.user == null) {
|
|
17
|
-
redirect("/auth/signin");
|
|
18
|
-
}
|
|
19
|
-
|
|
8
|
+
export default function HomePage() {
|
|
20
9
|
return (
|
|
21
10
|
<div className="space-y-8">
|
|
22
11
|
<h1 className="text-center text-2xl font-bold text-foreground">
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import type { Metadata } from "next";
|
|
2
|
-
import { headers } from "next/headers";
|
|
3
2
|
import { redirect } from "next/navigation";
|
|
4
3
|
import { Suspense } from "react";
|
|
5
|
-
import {
|
|
4
|
+
import { getServerSession } from "../../../../lib/auth";
|
|
6
5
|
import { CredentialsSignInForm } from "./CredentialsSignInForm";
|
|
7
6
|
|
|
8
7
|
export const metadata: Metadata = {
|
|
@@ -10,9 +9,7 @@ export const metadata: Metadata = {
|
|
|
10
9
|
};
|
|
11
10
|
|
|
12
11
|
export default async function SignInPage() {
|
|
13
|
-
const session = await
|
|
14
|
-
headers: await headers(),
|
|
15
|
-
});
|
|
12
|
+
const session = await getServerSession();
|
|
16
13
|
|
|
17
14
|
if (session?.user != null) {
|
|
18
15
|
redirect("/");
|