@cytario/web 2.2.5 → 2.2.7
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 +15 -0
- package/app/.server/db/redis.ts +16 -32
- package/app/.server/db/redisOptions.ts +72 -0
- package/app/components/GlobalSearch/SearchBar.tsx +1 -0
- package/app/routes/admin/bulkInvite/bulkInvite.form.tsx +27 -9
- package/app/routes/admin/createGroup/createGroup.form.tsx +16 -20
- package/app/routes/admin/inviteUser/inviteUser.form.tsx +50 -53
- package/app/routes/admin/updateUser/updateUser.form.tsx +45 -49
- package/app/routes/connections/connection.form.tsx +119 -145
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -230,6 +230,21 @@ cp .env.template .env # Pre-configured for the Podman cluster
|
|
|
230
230
|
npm run dev
|
|
231
231
|
```
|
|
232
232
|
|
|
233
|
+
### Session Cache (Redis/Valkey)
|
|
234
|
+
|
|
235
|
+
Sessions hold OAuth access/refresh/ID tokens and short-lived STS credentials. **TLS is required in production.** The app refuses to boot when `NODE_ENV !== "development"` unless one of the following is true:
|
|
236
|
+
|
|
237
|
+
| Env var | Value | Meaning |
|
|
238
|
+
| -------------------------------- | -------- | ---------------------------------------------------------------------------------------------------- |
|
|
239
|
+
| `REDIS_TLS` | `"true"` | Wrap the ioredis connection in TLS (recommended). |
|
|
240
|
+
| `REDIS_CA_CERT` | PEM | Optional CA bundle for self-signed deployments. Multi-line PEM string. |
|
|
241
|
+
| `REDIS_TLS_SERVER_NAME` | hostname | Optional SNI / certificate hostname override. |
|
|
242
|
+
| `REDIS_INSECURE_ALLOW_PLAINTEXT` | `"true"` | Explicit opt-out for trusted private networks. Logs a warning. Not for use on shared infrastructure. |
|
|
243
|
+
|
|
244
|
+
The local Podman cluster runs Valkey without TLS, which is allowed because `NODE_ENV=development`. Managed Valkey deployments (helm chart, AWS ElastiCache, etc.) should set `REDIS_TLS=true`. Valkey reuses the standard `6379` port for TLS when `tls.enabled` is set — it does not move the listener to `6380` and refuses plaintext on the same port — so leave `REDIS_PORT` at `6379` unless your provider explicitly publishes a separate TLS endpoint.
|
|
245
|
+
|
|
246
|
+
In the production cluster (see `cytario-infrastructure`, C-212) the Valkey leaf cert is signed by a cluster-internal CA managed by cert-manager. The CA's public cert is distributed to every namespace as a `cytario-internal-ca` ConfigMap by trust-manager, and the `cytario-web` helm chart's `redis.caCertConfigMap.{name,key}` wires it into the pod as `REDIS_CA_CERT` via `valueFrom.configMapKeyRef`. The app sees the PEM through the normal env var path — no code-side knowledge of the trust source is required.
|
|
247
|
+
|
|
233
248
|
### Database
|
|
234
249
|
|
|
235
250
|
PostgreSQL with [Prisma ORM](https://www.prisma.io/). Connection configured via `DATABASE_URL` in `.env`.
|
package/app/.server/db/redis.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import Redis from "ioredis";
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { buildRedisOptions } from "./redisOptions";
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Redis/Valkey client instance
|
|
@@ -14,55 +14,39 @@ import { cytarioConfig } from "~/config";
|
|
|
14
14
|
* - REDIS_PORT: Server port (default: 6379)
|
|
15
15
|
* - REDIS_USERNAME: Optional username for authenticated connections (Redis 6+ / Valkey)
|
|
16
16
|
* - REDIS_PASSWORD: Optional password for authenticated connections
|
|
17
|
+
* - REDIS_TLS: Set to "true" to wrap the connection in TLS (required in production)
|
|
18
|
+
* - REDIS_CA_CERT: Optional PEM-encoded CA certificate (string) for self-signed deployments
|
|
19
|
+
* - REDIS_TLS_SERVER_NAME: Optional SNI / certificate hostname override
|
|
20
|
+
* - REDIS_INSECURE_ALLOW_PLAINTEXT: Set to "true" to opt out of the production TLS requirement
|
|
17
21
|
*
|
|
18
22
|
* @example
|
|
19
|
-
* // Use with
|
|
20
|
-
* REDIS_HOST=redis.example.com
|
|
21
|
-
* REDIS_PORT=6379
|
|
22
|
-
*
|
|
23
|
-
* @example
|
|
24
|
-
* // Use with Valkey with authentication
|
|
23
|
+
* // Use with managed Valkey over TLS — Valkey reuses 6379 for TLS when tls.enabled is set
|
|
25
24
|
* REDIS_HOST=valkey.example.com
|
|
26
25
|
* REDIS_PORT=6379
|
|
27
26
|
* REDIS_USERNAME=myuser
|
|
28
27
|
* REDIS_PASSWORD=mypassword
|
|
28
|
+
* REDIS_TLS=true
|
|
29
29
|
*
|
|
30
30
|
* @example
|
|
31
|
-
* //
|
|
32
|
-
* REDIS_HOST=
|
|
31
|
+
* // Local development without TLS (only allowed when NODE_ENV=development)
|
|
32
|
+
* REDIS_HOST=localhost
|
|
33
33
|
* REDIS_PORT=6379
|
|
34
|
-
* REDIS_PASSWORD=mypassword
|
|
35
34
|
*/
|
|
36
35
|
|
|
37
|
-
const
|
|
38
|
-
redis: { host, port, username, password },
|
|
39
|
-
} = cytarioConfig;
|
|
36
|
+
const options = buildRedisOptions(process.env);
|
|
40
37
|
|
|
41
|
-
export const redis = new Redis(
|
|
42
|
-
host,
|
|
43
|
-
port,
|
|
44
|
-
// Only include username if provided (Redis 6+ ACL support)
|
|
45
|
-
...(username && { username }),
|
|
46
|
-
// Only include password if provided (backwards compatible)
|
|
47
|
-
...(password && { password }),
|
|
48
|
-
maxRetriesPerRequest: 3,
|
|
49
|
-
retryStrategy(times) {
|
|
50
|
-
const delay = Math.min(times * 50, 2000);
|
|
51
|
-
return delay;
|
|
52
|
-
},
|
|
53
|
-
lazyConnect: false,
|
|
54
|
-
});
|
|
38
|
+
export const redis = new Redis(options);
|
|
55
39
|
|
|
56
|
-
// Log connection errors
|
|
57
40
|
redis.on("error", (err) => {
|
|
58
41
|
console.error("Redis/Valkey connection error:", err);
|
|
59
42
|
});
|
|
60
43
|
|
|
61
44
|
redis.on("connect", () => {
|
|
62
|
-
const authInfo = username
|
|
63
|
-
? ` (authenticated as ${username})`
|
|
64
|
-
: password
|
|
45
|
+
const authInfo = options.username
|
|
46
|
+
? ` (authenticated as ${options.username})`
|
|
47
|
+
: options.password
|
|
65
48
|
? " (authenticated)"
|
|
66
49
|
: "";
|
|
67
|
-
|
|
50
|
+
const tlsInfo = options.tls ? " over TLS" : "";
|
|
51
|
+
console.log(`Connected to Redis/Valkey at ${options.host}:${options.port}${authInfo}${tlsInfo}`);
|
|
68
52
|
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { RedisOptions } from "ioredis";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Build ioredis connection options from environment variables.
|
|
5
|
+
*
|
|
6
|
+
* Recognised env vars:
|
|
7
|
+
* - `REDIS_HOST` (default: `localhost`)
|
|
8
|
+
* - `REDIS_PORT` (default: `6379`)
|
|
9
|
+
* - `REDIS_USERNAME` — optional ACL username
|
|
10
|
+
* - `REDIS_PASSWORD` — optional password
|
|
11
|
+
* - `REDIS_TLS` — `"true"` to wrap the connection in TLS
|
|
12
|
+
* - `REDIS_CA_CERT` — PEM-encoded CA certificate (string) used to verify
|
|
13
|
+
* the server when the cert chain is not in the system trust store
|
|
14
|
+
* - `REDIS_TLS_SERVER_NAME` — SNI / certificate hostname override
|
|
15
|
+
* - `REDIS_INSECURE_ALLOW_PLAINTEXT` — `"true"` to opt out of the
|
|
16
|
+
* production TLS requirement (escape hatch for trusted in-cluster
|
|
17
|
+
* networks; logs a warning)
|
|
18
|
+
*
|
|
19
|
+
* Fails fast outside development if TLS is off and the opt-out flag is
|
|
20
|
+
* not set — session blobs contain OAuth tokens and STS credentials and
|
|
21
|
+
* must not traverse plaintext links in production. See C-204.
|
|
22
|
+
*/
|
|
23
|
+
export function buildRedisOptions(env: Record<string, string | undefined>): RedisOptions {
|
|
24
|
+
const host = env.REDIS_HOST || "localhost";
|
|
25
|
+
const port = Number(env.REDIS_PORT) || 6379;
|
|
26
|
+
const username = env.REDIS_USERNAME;
|
|
27
|
+
const password = env.REDIS_PASSWORD;
|
|
28
|
+
const tlsEnabled = env.REDIS_TLS === "true";
|
|
29
|
+
const caCert = env.REDIS_CA_CERT;
|
|
30
|
+
const tlsServerName = env.REDIS_TLS_SERVER_NAME;
|
|
31
|
+
const allowPlaintext = env.REDIS_INSECURE_ALLOW_PLAINTEXT === "true";
|
|
32
|
+
const nodeEnv = env.NODE_ENV;
|
|
33
|
+
|
|
34
|
+
const isLocalEnv = nodeEnv === "development" || nodeEnv === "test";
|
|
35
|
+
|
|
36
|
+
if (!tlsEnabled && !isLocalEnv && !allowPlaintext) {
|
|
37
|
+
throw new Error(
|
|
38
|
+
"Refusing to start: Redis/Valkey TLS is disabled. Set REDIS_TLS=true " +
|
|
39
|
+
"(recommended) or REDIS_INSECURE_ALLOW_PLAINTEXT=true to opt out. " +
|
|
40
|
+
"See C-204 / OWASP A02:2021.",
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (!tlsEnabled && allowPlaintext && !isLocalEnv) {
|
|
45
|
+
console.warn(
|
|
46
|
+
"REDIS_INSECURE_ALLOW_PLAINTEXT=true — session tokens will traverse " +
|
|
47
|
+
"an unencrypted connection. Only safe on a trusted private network.",
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const options: RedisOptions = {
|
|
52
|
+
host,
|
|
53
|
+
port,
|
|
54
|
+
...(username && { username }),
|
|
55
|
+
...(password && { password }),
|
|
56
|
+
maxRetriesPerRequest: 3,
|
|
57
|
+
retryStrategy(times) {
|
|
58
|
+
const delay = Math.min(times * 50, 2000);
|
|
59
|
+
return delay;
|
|
60
|
+
},
|
|
61
|
+
lazyConnect: false,
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
if (tlsEnabled) {
|
|
65
|
+
options.tls = {
|
|
66
|
+
...(caCert && { ca: caCert }),
|
|
67
|
+
...(tlsServerName && { servername: tlsServerName }),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return options;
|
|
72
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Button, Checkbox,
|
|
1
|
+
import { Button, Checkbox, Icon, IconButton, Input, Select } from "@cytario/design";
|
|
2
2
|
import { Plus, X } from "lucide-react";
|
|
3
3
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
4
4
|
import { useSubmit } from "react-router";
|
|
@@ -150,9 +150,10 @@ export function BulkInviteForm({
|
|
|
150
150
|
renderItem={(item) => <ScopePill scope={item.id} />}
|
|
151
151
|
/>
|
|
152
152
|
) : (
|
|
153
|
-
<
|
|
153
|
+
<div className="flex flex-col gap-1">
|
|
154
|
+
<p className="text-sm font-medium text-(--color-text-primary)">Group Membership</p>
|
|
154
155
|
<p className="text-sm text-slate-400">No groups available in this scope.</p>
|
|
155
|
-
</
|
|
156
|
+
</div>
|
|
156
157
|
)}
|
|
157
158
|
<div className="flex items-center gap-2">
|
|
158
159
|
<Checkbox isSelected={enabled} onChange={setEnabled}>
|
|
@@ -164,16 +165,30 @@ export function BulkInviteForm({
|
|
|
164
165
|
</div>
|
|
165
166
|
</div>
|
|
166
167
|
|
|
167
|
-
{formError &&
|
|
168
|
+
{formError && (
|
|
169
|
+
<p role="alert" className="text-sm text-rose-600 mb-4">
|
|
170
|
+
{formError}
|
|
171
|
+
</p>
|
|
172
|
+
)}
|
|
168
173
|
|
|
169
174
|
<table ref={tableRef} className="w-full border-collapse">
|
|
170
175
|
<thead>
|
|
171
176
|
<tr className="text-left text-sm text-slate-500">
|
|
172
|
-
<th className="w-10 pr-2 py-2 font-medium"
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
<th className="px-1 py-2 font-medium">
|
|
176
|
-
|
|
177
|
+
<th scope="col" className="w-10 pr-2 py-2 font-medium">
|
|
178
|
+
#
|
|
179
|
+
</th>
|
|
180
|
+
<th scope="col" className="px-1 py-2 font-medium">
|
|
181
|
+
Email
|
|
182
|
+
</th>
|
|
183
|
+
<th scope="col" className="px-1 py-2 font-medium">
|
|
184
|
+
First Name
|
|
185
|
+
</th>
|
|
186
|
+
<th scope="col" className="px-1 py-2 font-medium">
|
|
187
|
+
Last Name
|
|
188
|
+
</th>
|
|
189
|
+
<th scope="col" className="w-8">
|
|
190
|
+
<span className="sr-only">Remove</span>
|
|
191
|
+
</th>
|
|
177
192
|
</tr>
|
|
178
193
|
</thead>
|
|
179
194
|
<tbody>
|
|
@@ -185,6 +200,7 @@ export function BulkInviteForm({
|
|
|
185
200
|
</td>
|
|
186
201
|
<td className="px-1 py-1">
|
|
187
202
|
<Input
|
|
203
|
+
aria-label={`Email, row ${i + 1}`}
|
|
188
204
|
value={row.email}
|
|
189
205
|
onChange={(value) => updateRow(i, "email", value)}
|
|
190
206
|
placeholder="email@example.com"
|
|
@@ -194,6 +210,7 @@ export function BulkInviteForm({
|
|
|
194
210
|
</td>
|
|
195
211
|
<td className="px-1 py-1">
|
|
196
212
|
<Input
|
|
213
|
+
aria-label={`First name, row ${i + 1}`}
|
|
197
214
|
value={row.firstName}
|
|
198
215
|
onChange={(value) => updateRow(i, "firstName", value)}
|
|
199
216
|
placeholder="First"
|
|
@@ -203,6 +220,7 @@ export function BulkInviteForm({
|
|
|
203
220
|
</td>
|
|
204
221
|
<td className="px-1 py-1">
|
|
205
222
|
<Input
|
|
223
|
+
aria-label={`Last name, row ${i + 1}`}
|
|
206
224
|
value={row.lastName}
|
|
207
225
|
onChange={(value) => updateRow(i, "lastName", value)}
|
|
208
226
|
placeholder="Last"
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Fieldset, Input } from "@cytario/design";
|
|
2
2
|
import { zodResolver } from "@hookform/resolvers/zod";
|
|
3
3
|
import { Controller, useForm, useWatch } from "react-hook-form";
|
|
4
4
|
import { useSubmit } from "react-router";
|
|
@@ -13,11 +13,7 @@ interface CreateGroupFormProps {
|
|
|
13
13
|
export function CreateGroupForm({ scope }: CreateGroupFormProps) {
|
|
14
14
|
const submit = useSubmit();
|
|
15
15
|
|
|
16
|
-
const {
|
|
17
|
-
control,
|
|
18
|
-
handleSubmit,
|
|
19
|
-
formState: { errors },
|
|
20
|
-
} = useForm<CreateGroupFormData>({
|
|
16
|
+
const { control, handleSubmit } = useForm<CreateGroupFormData>({
|
|
21
17
|
resolver: zodResolver(createGroupSchema),
|
|
22
18
|
defaultValues: { name: "" },
|
|
23
19
|
mode: "onBlur",
|
|
@@ -34,20 +30,20 @@ export function CreateGroupForm({ scope }: CreateGroupFormProps) {
|
|
|
34
30
|
return (
|
|
35
31
|
<form id="create-group-form" onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
|
36
32
|
<Fieldset>
|
|
37
|
-
<
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
33
|
+
<Controller
|
|
34
|
+
control={control}
|
|
35
|
+
name="name"
|
|
36
|
+
render={({ field, fieldState }) => (
|
|
37
|
+
<Input
|
|
38
|
+
label="Group name"
|
|
39
|
+
size="lg"
|
|
40
|
+
value={field.value}
|
|
41
|
+
onChange={field.onChange}
|
|
42
|
+
onBlur={field.onBlur}
|
|
43
|
+
errorMessage={fieldState.error?.message}
|
|
44
|
+
/>
|
|
45
|
+
)}
|
|
46
|
+
/>
|
|
51
47
|
</Fieldset>
|
|
52
48
|
|
|
53
49
|
{nameValue.trim() && (
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Checkbox,
|
|
1
|
+
import { Checkbox, Fieldset, Input, Select } from "@cytario/design";
|
|
2
2
|
import { zodResolver } from "@hookform/resolvers/zod";
|
|
3
3
|
import { useEffect } from "react";
|
|
4
4
|
import { Controller, useForm } from "react-hook-form";
|
|
@@ -22,12 +22,7 @@ export function InviteUserForm({
|
|
|
22
22
|
}: InviteUserFormProps) {
|
|
23
23
|
const submit = useSubmit();
|
|
24
24
|
|
|
25
|
-
const {
|
|
26
|
-
control,
|
|
27
|
-
handleSubmit,
|
|
28
|
-
reset,
|
|
29
|
-
formState: { errors },
|
|
30
|
-
} = useForm<InviteUserFormData>({
|
|
25
|
+
const { control, handleSubmit, reset } = useForm<InviteUserFormData>({
|
|
31
26
|
resolver: zodResolver(inviteUserSchema),
|
|
32
27
|
defaultValues: {
|
|
33
28
|
email: "",
|
|
@@ -65,67 +60,69 @@ export function InviteUserForm({
|
|
|
65
60
|
return (
|
|
66
61
|
<form id="invite-form" onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
|
67
62
|
<Fieldset>
|
|
68
|
-
<
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
<
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
<
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
63
|
+
<Controller
|
|
64
|
+
control={control}
|
|
65
|
+
name="email"
|
|
66
|
+
render={({ field, fieldState }) => (
|
|
67
|
+
<Input
|
|
68
|
+
label="Email"
|
|
69
|
+
type="email"
|
|
70
|
+
size="lg"
|
|
71
|
+
value={field.value}
|
|
72
|
+
onChange={field.onChange}
|
|
73
|
+
onBlur={field.onBlur}
|
|
74
|
+
errorMessage={fieldState.error?.message}
|
|
75
|
+
/>
|
|
76
|
+
)}
|
|
77
|
+
/>
|
|
78
|
+
<Controller
|
|
79
|
+
control={control}
|
|
80
|
+
name="firstName"
|
|
81
|
+
render={({ field, fieldState }) => (
|
|
82
|
+
<Input
|
|
83
|
+
label="First name"
|
|
84
|
+
size="lg"
|
|
85
|
+
value={field.value}
|
|
86
|
+
onChange={field.onChange}
|
|
87
|
+
onBlur={field.onBlur}
|
|
88
|
+
errorMessage={fieldState.error?.message}
|
|
89
|
+
/>
|
|
90
|
+
)}
|
|
91
|
+
/>
|
|
92
|
+
<Controller
|
|
93
|
+
control={control}
|
|
94
|
+
name="lastName"
|
|
95
|
+
render={({ field, fieldState }) => (
|
|
96
|
+
<Input
|
|
97
|
+
label="Last name"
|
|
98
|
+
size="lg"
|
|
99
|
+
value={field.value}
|
|
100
|
+
onChange={field.onChange}
|
|
101
|
+
onBlur={field.onBlur}
|
|
102
|
+
errorMessage={fieldState.error?.message}
|
|
103
|
+
/>
|
|
104
|
+
)}
|
|
105
|
+
/>
|
|
111
106
|
{groupOptions.length > 0 ? (
|
|
112
107
|
<Controller
|
|
113
108
|
control={control}
|
|
114
109
|
name="groupPath"
|
|
115
|
-
render={({ field }) => (
|
|
110
|
+
render={({ field, fieldState }) => (
|
|
116
111
|
<Select
|
|
117
112
|
label="Group Membership"
|
|
118
113
|
items={groupOptions.map((p) => ({ id: p, name: p }))}
|
|
119
114
|
selectedKey={field.value}
|
|
120
115
|
onSelectionChange={(key) => field.onChange(key as string)}
|
|
121
116
|
renderItem={(item) => <ScopePill scope={item.id} />}
|
|
117
|
+
errorMessage={fieldState.error?.message}
|
|
122
118
|
/>
|
|
123
119
|
)}
|
|
124
120
|
/>
|
|
125
121
|
) : (
|
|
126
|
-
<
|
|
122
|
+
<div className="flex flex-col gap-1">
|
|
123
|
+
<p className="text-sm font-medium text-(--color-text-primary)">Group Membership</p>
|
|
127
124
|
<p className="text-sm text-slate-400">No groups available in this scope.</p>
|
|
128
|
-
</
|
|
125
|
+
</div>
|
|
129
126
|
)}
|
|
130
127
|
<div className="flex items-center gap-2">
|
|
131
128
|
<Controller
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Checkbox,
|
|
1
|
+
import { Checkbox, Fieldset, H3, Input } from "@cytario/design";
|
|
2
2
|
import { zodResolver } from "@hookform/resolvers/zod";
|
|
3
3
|
import { useState } from "react";
|
|
4
4
|
import { Controller, useForm } from "react-hook-form";
|
|
@@ -30,11 +30,7 @@ export const UpdateUserForm = ({ user, groups, groupPaths }: UpdateUserFormProps
|
|
|
30
30
|
return ids;
|
|
31
31
|
});
|
|
32
32
|
|
|
33
|
-
const {
|
|
34
|
-
control,
|
|
35
|
-
handleSubmit,
|
|
36
|
-
formState: { errors },
|
|
37
|
-
} = useForm<UpdateUserFormData>({
|
|
33
|
+
const { control, handleSubmit } = useForm<UpdateUserFormData>({
|
|
38
34
|
resolver: zodResolver(updateUserSchema),
|
|
39
35
|
defaultValues: {
|
|
40
36
|
email: user.email,
|
|
@@ -136,49 +132,49 @@ export const UpdateUserForm = ({ user, groups, groupPaths }: UpdateUserFormProps
|
|
|
136
132
|
<>
|
|
137
133
|
<form id="update-form" onSubmit={handleSubmit(onSubmit)} className="">
|
|
138
134
|
<Fieldset>
|
|
139
|
-
<
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
<
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
<
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
135
|
+
<Controller
|
|
136
|
+
control={control}
|
|
137
|
+
name="email"
|
|
138
|
+
render={({ field, fieldState }) => (
|
|
139
|
+
<Input
|
|
140
|
+
label="Email"
|
|
141
|
+
type="email"
|
|
142
|
+
size="lg"
|
|
143
|
+
value={field.value}
|
|
144
|
+
onChange={field.onChange}
|
|
145
|
+
onBlur={field.onBlur}
|
|
146
|
+
errorMessage={fieldState.error?.message}
|
|
147
|
+
/>
|
|
148
|
+
)}
|
|
149
|
+
/>
|
|
150
|
+
<Controller
|
|
151
|
+
control={control}
|
|
152
|
+
name="firstName"
|
|
153
|
+
render={({ field, fieldState }) => (
|
|
154
|
+
<Input
|
|
155
|
+
label="First name"
|
|
156
|
+
size="lg"
|
|
157
|
+
value={field.value}
|
|
158
|
+
onChange={field.onChange}
|
|
159
|
+
onBlur={field.onBlur}
|
|
160
|
+
errorMessage={fieldState.error?.message}
|
|
161
|
+
/>
|
|
162
|
+
)}
|
|
163
|
+
/>
|
|
164
|
+
<Controller
|
|
165
|
+
control={control}
|
|
166
|
+
name="lastName"
|
|
167
|
+
render={({ field, fieldState }) => (
|
|
168
|
+
<Input
|
|
169
|
+
label="Last name"
|
|
170
|
+
size="lg"
|
|
171
|
+
value={field.value}
|
|
172
|
+
onChange={field.onChange}
|
|
173
|
+
onBlur={field.onBlur}
|
|
174
|
+
errorMessage={fieldState.error?.message}
|
|
175
|
+
/>
|
|
176
|
+
)}
|
|
177
|
+
/>
|
|
182
178
|
<div className="flex items-center gap-2">
|
|
183
179
|
<Controller
|
|
184
180
|
control={control}
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
2
|
Banner,
|
|
3
|
-
Field,
|
|
4
3
|
Fieldset,
|
|
5
4
|
FormWizard,
|
|
6
5
|
FormWizardNav,
|
|
@@ -97,14 +96,7 @@ export const ConnectionForm = ({
|
|
|
97
96
|
|
|
98
97
|
const [currentStep, setCurrentStep] = useState(initialStep);
|
|
99
98
|
|
|
100
|
-
const {
|
|
101
|
-
control,
|
|
102
|
-
handleSubmit,
|
|
103
|
-
setError,
|
|
104
|
-
setValue,
|
|
105
|
-
trigger,
|
|
106
|
-
formState: { errors },
|
|
107
|
-
} = useForm<ConnectBucketFormData>({
|
|
99
|
+
const { control, handleSubmit, setError, setValue, trigger } = useForm<ConnectBucketFormData>({
|
|
108
100
|
resolver: zodResolver(connectionSchema),
|
|
109
101
|
defaultValues: initialData
|
|
110
102
|
? {
|
|
@@ -212,165 +204,147 @@ export const ConnectionForm = ({
|
|
|
212
204
|
>
|
|
213
205
|
{currentStep === 0 && (
|
|
214
206
|
<Fieldset>
|
|
215
|
-
<
|
|
207
|
+
<Controller
|
|
208
|
+
name="providerType"
|
|
209
|
+
control={control}
|
|
210
|
+
render={({ field, fieldState }) => (
|
|
211
|
+
<Select
|
|
212
|
+
label="Provider"
|
|
213
|
+
items={providerItems}
|
|
214
|
+
renderItem={(item) => <ProviderPill provider={item.id} />}
|
|
215
|
+
selectedKey={field.value}
|
|
216
|
+
onSelectionChange={(key) => field.onChange(key)}
|
|
217
|
+
errorMessage={fieldState.error?.message}
|
|
218
|
+
/>
|
|
219
|
+
)}
|
|
220
|
+
/>
|
|
221
|
+
|
|
222
|
+
<Controller
|
|
223
|
+
name="s3Uri"
|
|
224
|
+
control={control}
|
|
225
|
+
render={({ field, fieldState }) => (
|
|
226
|
+
<Input
|
|
227
|
+
label="S3 URI"
|
|
228
|
+
description="Bucket name and optional path prefix."
|
|
229
|
+
value={field.value}
|
|
230
|
+
onChange={(val) => {
|
|
231
|
+
const trimmed = val.replace(/^s3:\/\//, "");
|
|
232
|
+
field.onChange(trimmed);
|
|
233
|
+
}}
|
|
234
|
+
onBlur={field.onBlur}
|
|
235
|
+
name={field.name}
|
|
236
|
+
placeholder="my-bucket/path/prefix"
|
|
237
|
+
prefix="s3://"
|
|
238
|
+
size="lg"
|
|
239
|
+
errorMessage={fieldState.error?.message}
|
|
240
|
+
/>
|
|
241
|
+
)}
|
|
242
|
+
/>
|
|
243
|
+
|
|
244
|
+
<Controller
|
|
245
|
+
name="name"
|
|
246
|
+
control={control}
|
|
247
|
+
render={({ field, fieldState }) => (
|
|
248
|
+
<Input
|
|
249
|
+
label="Name"
|
|
250
|
+
description="A friendly name, auto-suggested from the S3 URI."
|
|
251
|
+
value={field.value}
|
|
252
|
+
onChange={(val) => {
|
|
253
|
+
if (!isAutoUpdatingName.current) {
|
|
254
|
+
userEditedName.current = true;
|
|
255
|
+
}
|
|
256
|
+
field.onChange(val);
|
|
257
|
+
}}
|
|
258
|
+
onBlur={field.onBlur}
|
|
259
|
+
name={field.name}
|
|
260
|
+
placeholder="my-connection"
|
|
261
|
+
size="lg"
|
|
262
|
+
errorMessage={fieldState.error?.message}
|
|
263
|
+
/>
|
|
264
|
+
)}
|
|
265
|
+
/>
|
|
266
|
+
</Fieldset>
|
|
267
|
+
)}
|
|
268
|
+
|
|
269
|
+
{currentStep === 1 && (
|
|
270
|
+
<Fieldset>
|
|
271
|
+
{adminScopes.length > 0 && (
|
|
216
272
|
<Controller
|
|
217
|
-
name="
|
|
273
|
+
name="ownerScope"
|
|
218
274
|
control={control}
|
|
219
|
-
render={({ field }) => (
|
|
275
|
+
render={({ field, fieldState }) => (
|
|
220
276
|
<Select
|
|
221
|
-
|
|
222
|
-
|
|
277
|
+
label="Visibility"
|
|
278
|
+
description="Who can access this connection."
|
|
279
|
+
items={[
|
|
280
|
+
{ id: userId, name: "Personal" },
|
|
281
|
+
...adminScopes.map((str) => ({
|
|
282
|
+
id: str,
|
|
283
|
+
name: str,
|
|
284
|
+
})),
|
|
285
|
+
]}
|
|
223
286
|
selectedKey={field.value}
|
|
224
287
|
onSelectionChange={(key) => field.onChange(key)}
|
|
288
|
+
renderItem={(item) => <ScopePill scope={item.id} />}
|
|
289
|
+
errorMessage={fieldState.error?.message}
|
|
225
290
|
/>
|
|
226
291
|
)}
|
|
227
292
|
/>
|
|
228
|
-
</Field>
|
|
229
|
-
|
|
230
|
-
<Field
|
|
231
|
-
label="S3 URI"
|
|
232
|
-
description="Bucket name and optional path prefix."
|
|
233
|
-
error={errors.s3Uri}
|
|
234
|
-
>
|
|
235
|
-
<Controller
|
|
236
|
-
name="s3Uri"
|
|
237
|
-
control={control}
|
|
238
|
-
render={({ field }) => (
|
|
239
|
-
<Input
|
|
240
|
-
value={field.value}
|
|
241
|
-
onChange={(val) => {
|
|
242
|
-
const trimmed = val.replace(/^s3:\/\//, "");
|
|
243
|
-
field.onChange(trimmed);
|
|
244
|
-
}}
|
|
245
|
-
onBlur={field.onBlur}
|
|
246
|
-
name={field.name}
|
|
247
|
-
placeholder="my-bucket/path/prefix"
|
|
248
|
-
prefix="s3://"
|
|
249
|
-
size="lg"
|
|
250
|
-
/>
|
|
251
|
-
)}
|
|
252
|
-
/>
|
|
253
|
-
</Field>
|
|
254
|
-
|
|
255
|
-
<Field
|
|
256
|
-
label="Name"
|
|
257
|
-
description="A friendly name, auto-suggested from the S3 URI."
|
|
258
|
-
error={errors.name}
|
|
259
|
-
>
|
|
260
|
-
<Controller
|
|
261
|
-
name="name"
|
|
262
|
-
control={control}
|
|
263
|
-
render={({ field }) => (
|
|
264
|
-
<Input
|
|
265
|
-
value={field.value}
|
|
266
|
-
onChange={(val) => {
|
|
267
|
-
if (!isAutoUpdatingName.current) {
|
|
268
|
-
userEditedName.current = true;
|
|
269
|
-
}
|
|
270
|
-
field.onChange(val);
|
|
271
|
-
}}
|
|
272
|
-
onBlur={field.onBlur}
|
|
273
|
-
name={field.name}
|
|
274
|
-
placeholder="my-connection"
|
|
275
|
-
size="lg"
|
|
276
|
-
/>
|
|
277
|
-
)}
|
|
278
|
-
/>
|
|
279
|
-
</Field>
|
|
280
|
-
</Fieldset>
|
|
281
|
-
)}
|
|
282
|
-
|
|
283
|
-
{currentStep === 1 && (
|
|
284
|
-
<Fieldset>
|
|
285
|
-
{adminScopes.length > 0 && (
|
|
286
|
-
<Field
|
|
287
|
-
label="Visibility"
|
|
288
|
-
description="Who can access this connection."
|
|
289
|
-
error={errors.ownerScope}
|
|
290
|
-
>
|
|
291
|
-
<Controller
|
|
292
|
-
name="ownerScope"
|
|
293
|
-
control={control}
|
|
294
|
-
render={({ field }) => (
|
|
295
|
-
<Select
|
|
296
|
-
items={[
|
|
297
|
-
{ id: userId, name: "Personal" },
|
|
298
|
-
...adminScopes.map((str) => ({
|
|
299
|
-
id: str,
|
|
300
|
-
name: str,
|
|
301
|
-
})),
|
|
302
|
-
]}
|
|
303
|
-
selectedKey={field.value}
|
|
304
|
-
onSelectionChange={(key) => field.onChange(key)}
|
|
305
|
-
renderItem={(item) => <ScopePill scope={item.id} />}
|
|
306
|
-
/>
|
|
307
|
-
)}
|
|
308
|
-
/>
|
|
309
|
-
</Field>
|
|
310
293
|
)}
|
|
311
294
|
|
|
312
295
|
{isAWS ? (
|
|
313
296
|
<>
|
|
314
|
-
<Field
|
|
315
|
-
label="Role ARN"
|
|
316
|
-
description="IAM role Cytario assumes to access your S3 data."
|
|
317
|
-
error={errors.roleArn}
|
|
318
|
-
>
|
|
319
|
-
<Controller
|
|
320
|
-
name="roleArn"
|
|
321
|
-
control={control}
|
|
322
|
-
render={({ field }) => (
|
|
323
|
-
<Input
|
|
324
|
-
value={field.value}
|
|
325
|
-
onChange={field.onChange}
|
|
326
|
-
onBlur={field.onBlur}
|
|
327
|
-
name={field.name}
|
|
328
|
-
placeholder="arn:aws:iam::123456789012:role/MyRole"
|
|
329
|
-
size="lg"
|
|
330
|
-
/>
|
|
331
|
-
)}
|
|
332
|
-
/>
|
|
333
|
-
</Field>
|
|
334
|
-
|
|
335
|
-
<Field
|
|
336
|
-
label="Region"
|
|
337
|
-
description="AWS region where this bucket is located."
|
|
338
|
-
error={errors.bucketRegion}
|
|
339
|
-
>
|
|
340
|
-
<Controller
|
|
341
|
-
name="bucketRegion"
|
|
342
|
-
control={control}
|
|
343
|
-
render={({ field }) => (
|
|
344
|
-
<Select
|
|
345
|
-
items={regionItems}
|
|
346
|
-
selectedKey={field.value}
|
|
347
|
-
onSelectionChange={(key) => field.onChange(key)}
|
|
348
|
-
/>
|
|
349
|
-
)}
|
|
350
|
-
/>
|
|
351
|
-
</Field>
|
|
352
|
-
</>
|
|
353
|
-
) : (
|
|
354
|
-
<Field
|
|
355
|
-
label="Endpoint"
|
|
356
|
-
description="Endpoint URL of your S3-compatible storage."
|
|
357
|
-
error={errors.bucketEndpoint}
|
|
358
|
-
>
|
|
359
297
|
<Controller
|
|
360
|
-
name="
|
|
298
|
+
name="roleArn"
|
|
361
299
|
control={control}
|
|
362
|
-
render={({ field }) => (
|
|
300
|
+
render={({ field, fieldState }) => (
|
|
363
301
|
<Input
|
|
302
|
+
label="Role ARN"
|
|
303
|
+
description="IAM role Cytario assumes to access your S3 data."
|
|
364
304
|
value={field.value}
|
|
365
305
|
onChange={field.onChange}
|
|
366
306
|
onBlur={field.onBlur}
|
|
367
307
|
name={field.name}
|
|
368
|
-
placeholder="
|
|
308
|
+
placeholder="arn:aws:iam::123456789012:role/MyRole"
|
|
369
309
|
size="lg"
|
|
310
|
+
errorMessage={fieldState.error?.message}
|
|
311
|
+
/>
|
|
312
|
+
)}
|
|
313
|
+
/>
|
|
314
|
+
|
|
315
|
+
<Controller
|
|
316
|
+
name="bucketRegion"
|
|
317
|
+
control={control}
|
|
318
|
+
render={({ field, fieldState }) => (
|
|
319
|
+
<Select
|
|
320
|
+
label="Region"
|
|
321
|
+
description="AWS region where this bucket is located."
|
|
322
|
+
items={regionItems}
|
|
323
|
+
selectedKey={field.value}
|
|
324
|
+
onSelectionChange={(key) => field.onChange(key)}
|
|
325
|
+
errorMessage={fieldState.error?.message}
|
|
370
326
|
/>
|
|
371
327
|
)}
|
|
372
328
|
/>
|
|
373
|
-
|
|
329
|
+
</>
|
|
330
|
+
) : (
|
|
331
|
+
<Controller
|
|
332
|
+
name="bucketEndpoint"
|
|
333
|
+
control={control}
|
|
334
|
+
render={({ field, fieldState }) => (
|
|
335
|
+
<Input
|
|
336
|
+
label="Endpoint"
|
|
337
|
+
description="Endpoint URL of your S3-compatible storage."
|
|
338
|
+
value={field.value}
|
|
339
|
+
onChange={field.onChange}
|
|
340
|
+
onBlur={field.onBlur}
|
|
341
|
+
name={field.name}
|
|
342
|
+
placeholder="https://s3.cytario.com"
|
|
343
|
+
size="lg"
|
|
344
|
+
errorMessage={fieldState.error?.message}
|
|
345
|
+
/>
|
|
346
|
+
)}
|
|
347
|
+
/>
|
|
374
348
|
)}
|
|
375
349
|
</Fieldset>
|
|
376
350
|
)}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cytario/web",
|
|
3
|
-
"version": "2.2.
|
|
3
|
+
"version": "2.2.7",
|
|
4
4
|
"description": "Cytario Web — scientific imaging data browser and viewer for OME-TIFF, OME-Zarr, Parquet and GeoTIFF on S3-compatible storage.",
|
|
5
5
|
"license": "AGPL-3.0",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -55,8 +55,8 @@
|
|
|
55
55
|
"codegen:check": "tsx scripts/codegen-check.ts",
|
|
56
56
|
"dev": "node bin/cytario-web.mjs dev",
|
|
57
57
|
"predev": "node scripts/prebuild.mjs && if [ -L node_modules/@cytario/design ]; then npm unlink @cytario/design --no-save 2>/dev/null && npm install @cytario/design --quiet; fi",
|
|
58
|
-
"predev:design": "cd ../cytario-design && npm link --quiet && cd - > /dev/null && npm link @cytario/design --quiet && for p in react react-dom react-aria-components; do rm -rf ../cytario-design/node_modules/$p && ln -s \"$(pwd)/node_modules/$p\" ../cytario-design/node_modules/$p; done",
|
|
59
|
-
"dev:design": "concurrently -n tsup,storybook,web -c blue,magenta,green \"cd ../cytario-design && npx tsup --watch\" \"cd ../cytario-design && npm run dev\" \"node bin/cytario-web.mjs dev\"",
|
|
58
|
+
"predev:design": "cd ../cytario-design && npm link --quiet && cd - > /dev/null && npm link @cytario/design --quiet && for p in react react-dom react-aria-components lucide-react; do rm -rf ../cytario-design/node_modules/$p && ln -s \"$(pwd)/node_modules/$p\" ../cytario-design/node_modules/$p; done && cd ../cytario-design && npm run build:css",
|
|
59
|
+
"dev:design": "concurrently -n tsup,css,storybook,web -c blue,yellow,magenta,green \"cd ../cytario-design && npx tsup --watch\" \"cd ../cytario-design && npx @tailwindcss/cli -i src/styles/tailwind.css -o dist/index.css --watch\" \"cd ../cytario-design && npm run dev\" \"node bin/cytario-web.mjs dev\"",
|
|
60
60
|
"lint": "eslint --cache --cache-location ./node_modules/.cache/eslint .",
|
|
61
61
|
"prestart": "npm run build:server",
|
|
62
62
|
"start": "node bin/cytario-web.mjs start",
|
|
@@ -72,7 +72,7 @@
|
|
|
72
72
|
"@aws-sdk/client-sts": "^3.687.0",
|
|
73
73
|
"@aws-sdk/s3-request-presigner": "^3.693.0",
|
|
74
74
|
"@cornerstonejs/codec-openjpeg": "^1.3.0",
|
|
75
|
-
"@cytario/design": "^
|
|
75
|
+
"@cytario/design": "^4.0.0",
|
|
76
76
|
"@deck.gl/core": "~9.1.15",
|
|
77
77
|
"@deck.gl/extensions": "~9.1.15",
|
|
78
78
|
"@deck.gl/geo-layers": "~9.1.15",
|