@percepta/create 3.4.0 → 3.4.2
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/package.json +1 -1
- package/templates/monorepo/auth/package.json +2 -1
- package/templates/monorepo/auth/src/principals.ts +11 -0
- package/templates/monorepo/package.json.template +1 -1
- package/templates/webapp/README.md +26 -0
- package/templates/webapp/agent-skills/deploy.md +1 -1
- package/templates/webapp/deploy/README.md +1 -1
- package/templates/webapp/e2e/rbac.spec.ts +136 -0
- package/templates/webapp/eslint.config.mjs +6 -0
- package/templates/webapp/gitignore.template +2 -0
- package/templates/webapp/package.json.template +8 -3
- package/templates/webapp/playwright.config.ts +33 -0
- package/templates/webapp/src/access/access.manifest.ts +5 -11
- package/templates/webapp/src/app/(admin)/admin/page.tsx +64 -149
- package/templates/webapp/src/services/access/AppAccessControl.ts +5 -31
- package/templates/webapp/scripts/deploy-percepta-test.ts +0 -1112
- package/templates/webapp/scripts/open-ryvn-deploy-pr.ts +0 -497
- package/templates/webapp/src/app/(admin)/admin/_components/PrincipalMultiInput.tsx +0 -248
|
@@ -1,248 +0,0 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import {
|
|
4
|
-
Badge,
|
|
5
|
-
Command,
|
|
6
|
-
CommandEmpty,
|
|
7
|
-
CommandGroup,
|
|
8
|
-
CommandInput,
|
|
9
|
-
CommandItem,
|
|
10
|
-
CommandList,
|
|
11
|
-
Popover,
|
|
12
|
-
PopoverContent,
|
|
13
|
-
PopoverTrigger,
|
|
14
|
-
} from "@percepta/design";
|
|
15
|
-
import { useCallback, useMemo, useState, useTransition } from "react";
|
|
16
|
-
|
|
17
|
-
export interface PrincipalOption {
|
|
18
|
-
readonly detail: string;
|
|
19
|
-
readonly disabled?: boolean;
|
|
20
|
-
readonly disabledReason?: string;
|
|
21
|
-
readonly label: string;
|
|
22
|
-
readonly subject: string;
|
|
23
|
-
readonly type: "Group" | "Principal" | "User";
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
interface HiddenField {
|
|
27
|
-
readonly name: string;
|
|
28
|
-
readonly value: string;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
interface PrincipalMultiInputProps {
|
|
32
|
-
readonly action: (formData: FormData) => Promise<void>;
|
|
33
|
-
readonly disabled: boolean;
|
|
34
|
-
readonly hiddenFields?: readonly HiddenField[];
|
|
35
|
-
readonly options: readonly PrincipalOption[];
|
|
36
|
-
readonly selectedSubjects: readonly string[];
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export function PrincipalMultiInput({
|
|
40
|
-
action,
|
|
41
|
-
disabled,
|
|
42
|
-
hiddenFields = [],
|
|
43
|
-
options,
|
|
44
|
-
selectedSubjects,
|
|
45
|
-
}: PrincipalMultiInputProps) {
|
|
46
|
-
const [isPickerOpen, setIsPickerOpen] = useState(false);
|
|
47
|
-
const [isPending, startTransition] = useTransition();
|
|
48
|
-
const [query, setQuery] = useState("");
|
|
49
|
-
const [selected, setSelected] = useState<readonly string[]>(
|
|
50
|
-
selectedSubjects,
|
|
51
|
-
);
|
|
52
|
-
const optionBySubject = useMemo(
|
|
53
|
-
() => new Map(options.map((option) => [option.subject, option])),
|
|
54
|
-
[options],
|
|
55
|
-
);
|
|
56
|
-
const selectedSet = useMemo(() => new Set(selected), [selected]);
|
|
57
|
-
const normalizedQuery = query.trim().toLowerCase();
|
|
58
|
-
|
|
59
|
-
const selectedOptions = useMemo(
|
|
60
|
-
() =>
|
|
61
|
-
selected.map(
|
|
62
|
-
(subject): PrincipalOption =>
|
|
63
|
-
optionBySubject.get(subject) ?? {
|
|
64
|
-
detail: "Unknown principal",
|
|
65
|
-
label: subject,
|
|
66
|
-
subject,
|
|
67
|
-
type: "Principal",
|
|
68
|
-
},
|
|
69
|
-
),
|
|
70
|
-
[optionBySubject, selected],
|
|
71
|
-
);
|
|
72
|
-
|
|
73
|
-
const filteredOptions = useMemo(
|
|
74
|
-
() =>
|
|
75
|
-
options.filter((option) => {
|
|
76
|
-
if (selectedSet.has(option.subject)) {
|
|
77
|
-
return false;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
if (normalizedQuery.length === 0) {
|
|
81
|
-
return true;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
return `${option.label} ${option.detail} ${option.type}`
|
|
85
|
-
.toLowerCase()
|
|
86
|
-
.includes(normalizedQuery);
|
|
87
|
-
}),
|
|
88
|
-
[normalizedQuery, options, selectedSet],
|
|
89
|
-
);
|
|
90
|
-
|
|
91
|
-
const submitSelection = useCallback(
|
|
92
|
-
(nextSelected: readonly string[]) => {
|
|
93
|
-
const formData = new FormData();
|
|
94
|
-
for (const field of hiddenFields) {
|
|
95
|
-
formData.append(field.name, field.value);
|
|
96
|
-
}
|
|
97
|
-
for (const subject of nextSelected) {
|
|
98
|
-
formData.append("subjects", subject);
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
startTransition(async () => {
|
|
102
|
-
await action(formData);
|
|
103
|
-
});
|
|
104
|
-
},
|
|
105
|
-
[action, hiddenFields],
|
|
106
|
-
);
|
|
107
|
-
|
|
108
|
-
const isDisabled = disabled || isPending;
|
|
109
|
-
|
|
110
|
-
const handleAddOption = useCallback(
|
|
111
|
-
(subject: string) => {
|
|
112
|
-
const option = optionBySubject.get(subject);
|
|
113
|
-
if (
|
|
114
|
-
isDisabled ||
|
|
115
|
-
option?.disabled === true ||
|
|
116
|
-
selected.includes(subject)
|
|
117
|
-
) {
|
|
118
|
-
return;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
const nextSelected = [...selected, subject];
|
|
122
|
-
setSelected(nextSelected);
|
|
123
|
-
submitSelection(nextSelected);
|
|
124
|
-
setQuery("");
|
|
125
|
-
setIsPickerOpen(false);
|
|
126
|
-
},
|
|
127
|
-
[isDisabled, optionBySubject, selected, submitSelection],
|
|
128
|
-
);
|
|
129
|
-
|
|
130
|
-
const handleRemoveOption = useCallback(
|
|
131
|
-
(subject: string) => {
|
|
132
|
-
if (isDisabled) {
|
|
133
|
-
return;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
const nextSelected = selected.filter(
|
|
137
|
-
(currentSubject) => currentSubject !== subject,
|
|
138
|
-
);
|
|
139
|
-
setSelected(nextSelected);
|
|
140
|
-
submitSelection(nextSelected);
|
|
141
|
-
},
|
|
142
|
-
[isDisabled, selected, submitSelection],
|
|
143
|
-
);
|
|
144
|
-
|
|
145
|
-
return (
|
|
146
|
-
<Popover
|
|
147
|
-
open={isPickerOpen}
|
|
148
|
-
onOpenChange={(open) => {
|
|
149
|
-
if (!isDisabled) {
|
|
150
|
-
setIsPickerOpen(open);
|
|
151
|
-
}
|
|
152
|
-
}}
|
|
153
|
-
>
|
|
154
|
-
<PopoverTrigger asChild={true}>
|
|
155
|
-
<div
|
|
156
|
-
aria-busy={isPending}
|
|
157
|
-
aria-disabled={isDisabled}
|
|
158
|
-
className="flex min-h-12 cursor-text flex-wrap items-center gap-2 rounded-md border border-border bg-background p-2 data-[disabled=true]:cursor-not-allowed data-[disabled=true]:opacity-60"
|
|
159
|
-
data-disabled={isDisabled}
|
|
160
|
-
role="button"
|
|
161
|
-
tabIndex={isDisabled ? -1 : 0}
|
|
162
|
-
>
|
|
163
|
-
{selectedOptions.map((option) => (
|
|
164
|
-
<SelectedPrincipalBadge
|
|
165
|
-
disabled={isDisabled}
|
|
166
|
-
key={option.subject}
|
|
167
|
-
onRemove={handleRemoveOption}
|
|
168
|
-
option={option}
|
|
169
|
-
/>
|
|
170
|
-
))}
|
|
171
|
-
{selectedOptions.length === 0 ? (
|
|
172
|
-
<span className="min-w-40 flex-1 text-sm text-muted-foreground">
|
|
173
|
-
{isDisabled ? "No users or groups assigned" : "Search users or groups"}
|
|
174
|
-
</span>
|
|
175
|
-
) : (
|
|
176
|
-
<span aria-hidden={true} className="min-w-8 flex-1" />
|
|
177
|
-
)}
|
|
178
|
-
</div>
|
|
179
|
-
</PopoverTrigger>
|
|
180
|
-
<PopoverContent align="start" className="w-96 p-0">
|
|
181
|
-
<Command shouldFilter={false}>
|
|
182
|
-
<CommandInput
|
|
183
|
-
onValueChange={setQuery}
|
|
184
|
-
placeholder="Search users or groups"
|
|
185
|
-
value={query}
|
|
186
|
-
/>
|
|
187
|
-
<CommandList>
|
|
188
|
-
<CommandEmpty>No matches</CommandEmpty>
|
|
189
|
-
<CommandGroup>
|
|
190
|
-
{filteredOptions.map((option) => (
|
|
191
|
-
<CommandItem
|
|
192
|
-
disabled={isDisabled || option.disabled === true}
|
|
193
|
-
key={option.subject}
|
|
194
|
-
onSelect={handleAddOption}
|
|
195
|
-
title={option.disabledReason}
|
|
196
|
-
value={option.subject}
|
|
197
|
-
>
|
|
198
|
-
<span className="min-w-0 flex-1">
|
|
199
|
-
<span className="block truncate font-medium text-foreground">
|
|
200
|
-
{option.label}
|
|
201
|
-
</span>
|
|
202
|
-
<span className="block truncate text-xs text-muted-foreground">
|
|
203
|
-
{option.detail} / {option.type}
|
|
204
|
-
</span>
|
|
205
|
-
</span>
|
|
206
|
-
</CommandItem>
|
|
207
|
-
))}
|
|
208
|
-
</CommandGroup>
|
|
209
|
-
</CommandList>
|
|
210
|
-
</Command>
|
|
211
|
-
</PopoverContent>
|
|
212
|
-
</Popover>
|
|
213
|
-
);
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
function SelectedPrincipalBadge({
|
|
217
|
-
disabled,
|
|
218
|
-
onRemove,
|
|
219
|
-
option,
|
|
220
|
-
}: {
|
|
221
|
-
readonly disabled: boolean;
|
|
222
|
-
readonly onRemove: (subject: string) => void;
|
|
223
|
-
readonly option: PrincipalOption;
|
|
224
|
-
}) {
|
|
225
|
-
const handleRemove = useCallback(() => {
|
|
226
|
-
onRemove(option.subject);
|
|
227
|
-
}, [onRemove, option.subject]);
|
|
228
|
-
|
|
229
|
-
return (
|
|
230
|
-
<span
|
|
231
|
-
className="max-w-full"
|
|
232
|
-
onClick={(event) => event.stopPropagation()}
|
|
233
|
-
onKeyDown={(event) => event.stopPropagation()}
|
|
234
|
-
>
|
|
235
|
-
<Badge
|
|
236
|
-
className="max-w-full gap-2 py-1 text-sm"
|
|
237
|
-
disabled={disabled}
|
|
238
|
-
onRemove={handleRemove}
|
|
239
|
-
variant="secondary"
|
|
240
|
-
>
|
|
241
|
-
<span className="truncate">{option.label}</span>
|
|
242
|
-
<span className="text-xs font-normal text-muted-foreground">
|
|
243
|
-
{option.type}
|
|
244
|
-
</span>
|
|
245
|
-
</Badge>
|
|
246
|
-
</span>
|
|
247
|
-
);
|
|
248
|
-
}
|