@cytario/web 2.2.6 → 2.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.
@@ -14,6 +14,7 @@ import { buildRedisOptions } from "./redisOptions";
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_KEY_PREFIX: Optional prefix prepended to every key (e.g. "cytario-web:")
17
18
  * - REDIS_TLS: Set to "true" to wrap the connection in TLS (required in production)
18
19
  * - REDIS_CA_CERT: Optional PEM-encoded CA certificate (string) for self-signed deployments
19
20
  * - REDIS_TLS_SERVER_NAME: Optional SNI / certificate hostname override
@@ -35,6 +36,14 @@ import { buildRedisOptions } from "./redisOptions";
35
36
 
36
37
  const options = buildRedisOptions(process.env);
37
38
 
39
+ const auth = options.password ? "on" : "off";
40
+ const tls = options.tls ? "on" : "off";
41
+ const who = options.username ? ` as ${options.username}` : "";
42
+ const prefix = options.keyPrefix ? `, prefix "${options.keyPrefix}"` : "";
43
+ console.log(
44
+ `Redis/Valkey config: ${options.host}:${options.port}${who} (AUTH ${auth}, TLS ${tls}${prefix})`,
45
+ );
46
+
38
47
  export const redis = new Redis(options);
39
48
 
40
49
  redis.on("error", (err) => {
@@ -42,11 +51,5 @@ redis.on("error", (err) => {
42
51
  });
43
52
 
44
53
  redis.on("connect", () => {
45
- const authInfo = options.username
46
- ? ` (authenticated as ${options.username})`
47
- : options.password
48
- ? " (authenticated)"
49
- : "";
50
- const tlsInfo = options.tls ? " over TLS" : "";
51
- console.log(`Connected to Redis/Valkey at ${options.host}:${options.port}${authInfo}${tlsInfo}`);
54
+ console.log(`Connected to Redis/Valkey at ${options.host}:${options.port}`);
52
55
  });
@@ -7,7 +7,10 @@ import type { RedisOptions } from "ioredis";
7
7
  * - `REDIS_HOST` (default: `localhost`)
8
8
  * - `REDIS_PORT` (default: `6379`)
9
9
  * - `REDIS_USERNAME` — optional ACL username
10
- * - `REDIS_PASSWORD` — optional password
10
+ * - `REDIS_PASSWORD` — password; required outside development
11
+ * - `REDIS_KEY_PREFIX` — optional prefix prepended to every key issued by
12
+ * this client (e.g. `"cytario-web:"`). Lets operators scope the app to a
13
+ * single ACL keyspace pattern without code changes.
11
14
  * - `REDIS_TLS` — `"true"` to wrap the connection in TLS
12
15
  * - `REDIS_CA_CERT` — PEM-encoded CA certificate (string) used to verify
13
16
  * the server when the cert chain is not in the system trust store
@@ -16,9 +19,13 @@ import type { RedisOptions } from "ioredis";
16
19
  * production TLS requirement (escape hatch for trusted in-cluster
17
20
  * networks; logs a warning)
18
21
  *
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
+ * Fails fast outside development if:
23
+ * - TLS is off and the opt-out flag is not set, or
24
+ * - `REDIS_PASSWORD` is empty.
25
+ *
26
+ * Session blobs contain OAuth tokens and STS credentials and must never
27
+ * traverse plaintext links or be readable by anonymous clients in
28
+ * production.
22
29
  */
23
30
  export function buildRedisOptions(env: Record<string, string | undefined>): RedisOptions {
24
31
  const host = env.REDIS_HOST || "localhost";
@@ -29,15 +36,22 @@ export function buildRedisOptions(env: Record<string, string | undefined>): Redi
29
36
  const caCert = env.REDIS_CA_CERT;
30
37
  const tlsServerName = env.REDIS_TLS_SERVER_NAME;
31
38
  const allowPlaintext = env.REDIS_INSECURE_ALLOW_PLAINTEXT === "true";
39
+ const keyPrefix = env.REDIS_KEY_PREFIX;
32
40
  const nodeEnv = env.NODE_ENV;
33
41
 
34
42
  const isLocalEnv = nodeEnv === "development" || nodeEnv === "test";
35
43
 
44
+ if (!isLocalEnv && !password) {
45
+ throw new Error(
46
+ "Refusing to start: Redis/Valkey AUTH is disabled. Set REDIS_PASSWORD " +
47
+ "to a non-empty value so the session store rejects anonymous clients.",
48
+ );
49
+ }
50
+
36
51
  if (!tlsEnabled && !isLocalEnv && !allowPlaintext) {
37
52
  throw new Error(
38
53
  "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.",
54
+ "(recommended) or REDIS_INSECURE_ALLOW_PLAINTEXT=true to opt out.",
41
55
  );
42
56
  }
43
57
 
@@ -53,6 +67,7 @@ export function buildRedisOptions(env: Record<string, string | undefined>): Redi
53
67
  port,
54
68
  ...(username && { username }),
55
69
  ...(password && { password }),
70
+ ...(keyPrefix && { keyPrefix }),
56
71
  maxRetriesPerRequest: 3,
57
72
  retryStrategy(times) {
58
73
  const delay = Math.min(times * 50, 2000);
@@ -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)}
package/app/config.ts CHANGED
@@ -22,7 +22,7 @@ interface CytarioConfig {
22
22
  host: string;
23
23
  /** Optional username for authenticated connections (Redis 6+ ACL / Valkey) */
24
24
  username?: string;
25
- /** Optional password for authenticated connections */
25
+ /** Password for authenticated connections. Optional in development only */
26
26
  password?: string;
27
27
  };
28
28
  cookie: CookieOptions;
@@ -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
  )}
@@ -85,7 +85,7 @@ function runStart(forwardedArgs) {
85
85
  const child = spawn(process.execPath, [serverEntry, ...forwardedArgs], {
86
86
  cwd: PACKAGE_ROOT,
87
87
  stdio: "inherit",
88
- env: { ...process.env, NODE_ENV: "production" },
88
+ env: { ...process.env, NODE_ENV: process.env.NODE_ENV ?? "production" },
89
89
  });
90
90
  child.on("exit", (code, signal) => {
91
91
  if (signal) process.kill(process.pid, signal);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cytario/web",
3
- "version": "2.2.6",
3
+ "version": "2.3.0",
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",