@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 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`.
@@ -1,6 +1,6 @@
1
1
  import Redis from "ioredis";
2
2
 
3
- import { cytarioConfig } from "~/config";
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 Redis without authentication
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
- * // Use with Redis/Valkey with password-only authentication (legacy)
32
- * REDIS_HOST=redis.example.com
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
- console.log(`Connected to Redis/Valkey at ${host}:${port}${authInfo}`);
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
+ }
@@ -11,6 +11,7 @@ export function SearchBar({ value, onChange, onClear }: SearchBarProps) {
11
11
  return (
12
12
  <div className="flex">
13
13
  <Input
14
+ aria-label="Search"
14
15
  value={value}
15
16
  onChange={onChange}
16
17
  onFocus={() => onChange(value)}
@@ -1,4 +1,4 @@
1
- import { Button, Checkbox, Field, Icon, IconButton, Input, Select } from "@cytario/design";
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
- <Field label="Group Membership">
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
- </Field>
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 && <p className="text-sm text-rose-600 mb-4">{formError}</p>}
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">#</th>
173
- <th className="px-1 py-2 font-medium">Email</th>
174
- <th className="px-1 py-2 font-medium">First Name</th>
175
- <th className="px-1 py-2 font-medium">Last Name</th>
176
- <th className="w-8" />
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 { Field, Fieldset, Input } from "@cytario/design";
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
- <Field label="Group name" error={errors.name}>
38
- <Controller
39
- control={control}
40
- name="name"
41
- render={({ field }) => (
42
- <Input
43
- size="lg"
44
- value={field.value}
45
- onChange={field.onChange}
46
- onBlur={field.onBlur}
47
- />
48
- )}
49
- />
50
- </Field>
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, Field, Fieldset, Input, Select } from "@cytario/design";
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
- <Field label="Email" error={errors.email}>
69
- <Controller
70
- control={control}
71
- name="email"
72
- render={({ field }) => (
73
- <Input
74
- type="email"
75
- size="lg"
76
- value={field.value}
77
- onChange={field.onChange}
78
- onBlur={field.onBlur}
79
- />
80
- )}
81
- />
82
- </Field>
83
- <Field label="First name" error={errors.firstName}>
84
- <Controller
85
- control={control}
86
- name="firstName"
87
- render={({ field }) => (
88
- <Input
89
- size="lg"
90
- value={field.value}
91
- onChange={field.onChange}
92
- onBlur={field.onBlur}
93
- />
94
- )}
95
- />
96
- </Field>
97
- <Field label="Last name" error={errors.lastName}>
98
- <Controller
99
- control={control}
100
- name="lastName"
101
- render={({ field }) => (
102
- <Input
103
- size="lg"
104
- value={field.value}
105
- onChange={field.onChange}
106
- onBlur={field.onBlur}
107
- />
108
- )}
109
- />
110
- </Field>
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
- <Field label="Group Membership">
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
- </Field>
125
+ </div>
129
126
  )}
130
127
  <div className="flex items-center gap-2">
131
128
  <Controller
@@ -1,4 +1,4 @@
1
- import { Checkbox, Field, Fieldset, H3, Input } from "@cytario/design";
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
- <Field label="Email" error={errors.email}>
140
- <Controller
141
- control={control}
142
- name="email"
143
- render={({ field }) => (
144
- <Input
145
- type="email"
146
- size="lg"
147
- value={field.value}
148
- onChange={field.onChange}
149
- onBlur={field.onBlur}
150
- />
151
- )}
152
- />
153
- </Field>
154
- <Field label="First name" error={errors.firstName}>
155
- <Controller
156
- control={control}
157
- name="firstName"
158
- render={({ field }) => (
159
- <Input
160
- size="lg"
161
- value={field.value}
162
- onChange={field.onChange}
163
- onBlur={field.onBlur}
164
- />
165
- )}
166
- />
167
- </Field>
168
- <Field label="Last name" error={errors.lastName}>
169
- <Controller
170
- control={control}
171
- name="lastName"
172
- render={({ field }) => (
173
- <Input
174
- size="lg"
175
- value={field.value}
176
- onChange={field.onChange}
177
- onBlur={field.onBlur}
178
- />
179
- )}
180
- />
181
- </Field>
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
- <Field label="Provider" error={errors.providerType}>
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="providerType"
273
+ name="ownerScope"
218
274
  control={control}
219
- render={({ field }) => (
275
+ render={({ field, fieldState }) => (
220
276
  <Select
221
- items={providerItems}
222
- renderItem={(item) => <ProviderPill provider={item.id} />}
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="bucketEndpoint"
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="https://s3.cytario.com"
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
- </Field>
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.5",
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": "^3.4.0",
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",