diff --git a/apps/api/src/locales/@vitnode/core/en.json b/apps/api/src/locales/@vitnode/core/en.json index 4a24efa41..528a31ba2 100644 --- a/apps/api/src/locales/@vitnode/core/en.json +++ b/apps/api/src/locales/@vitnode/core/en.json @@ -568,9 +568,22 @@ "submit": "Save changes", "success": "Role updated" }, + "tabs": { + "general": "General", + "content": "Content" + }, "form": { "name": "Name", - "color": "Color" + "color": "Color", + "upload": { + "allow": "Allow upload files", + "total_max_storage": "Total Max Storage", + "max_storage_for_submit": "Max Storage for Submit", + "max_storage_for_submit_desc": "If you set 1000, user can upload only 1 file with size 1000kB or 2 files with size 500kB for each submit.", + "in_unit": "in kB", + "or": "or", + "unlimited": "Unlimited" + } } }, "staff": { diff --git a/apps/docs/content/docs/dev/search.mdx b/apps/docs/content/docs/dev/search.mdx index 27a3ef977..9012af24a 100644 --- a/apps/docs/content/docs/dev/search.mdx +++ b/apps/docs/content/docs/dev/search.mdx @@ -8,9 +8,9 @@ VitNode ships a content **search & discovery** system. Every searchable item is projected into a single canonical table, `core_search_index`, which powers two public pages and the AdminCP: -- **`/search`** — full-text search with relevance ranking and filters. -- **`/discover`** — the same index sorted newest-first (a community timeline). -- **AdminCP → user profile → Timeline** — a single member's indexed activity. +- **`/search`** - full-text search with relevance ranking and filters. +- **`/discover`** - the same index sorted newest-first (a community timeline). +- **AdminCP → user profile → Timeline** - a single member's indexed activity. Search and discovery are **locale-aware**: results follow the active language, so a member browsing in Polish sees Polish titles, snippets and links. See @@ -18,9 +18,9 @@ a member browsing in Polish sees Polish titles, snippets and links. See The query engine is **pluggable**. The default requires zero setup: -- **Postgres** (default) — Postgres full-text search (`tsvector` + GIN, title +- **Postgres** (default) - Postgres full-text search (`tsvector` + GIN, title weighted above body). Great for small and medium communities. -- **Elasticsearch** (`@vitnode/elasticsearch`) — offloads querying to an external +- **Elasticsearch** (`@vitnode/elasticsearch`) - offloads querying to an external cluster and unlocks advanced ranking (time-decay, author-boost). `core_search_index` is always the source of truth, so switching engines is a @@ -79,13 +79,13 @@ for (const languageCode of enabledLanguageCodes) { The `/search` and `/discover` requests send the active locale as a `lang` query param, and the query keeps only rows for that locale. Content that isn't -translated can leave `languageCode` empty (`""`) — those rows are **language +translated can leave `languageCode` empty (`""`) - those rows are **language agnostic** and match every locale, so single-language plugins need no changes. - Postgres full-text ranking uses the `english` text-search configuration for all - languages (there is no bundled dictionary for most locales). Matching still works - across languages; only stemming and stop-words are English-tuned. + Postgres full-text ranking uses the `english` text-search configuration for + all languages (there is no bundled dictionary for most locales). Matching + still works across languages; only stemming and stop-words are English-tuned. ## Registering a rebuild indexer diff --git a/apps/docs/content/docs/ui/alert.mdx b/apps/docs/content/docs/ui/alert.mdx index 7e625461e..2ffd36be4 100644 --- a/apps/docs/content/docs/ui/alert.mdx +++ b/apps/docs/content/docs/ui/alert.mdx @@ -14,8 +14,8 @@ import { Alert, AlertDescription, AlertTitle, -} from '@vitnode/core/components/ui/alert'; -import { TriangleAlertIcon } from 'lucide-react'; +} from "@vitnode/core/components/ui/alert"; +import { TriangleAlertIcon } from "lucide-react"; ``` ```tsx @@ -26,14 +26,14 @@ import { TriangleAlertIcon } from 'lucide-react'; ``` -An optional leading icon is picked up automatically — render any icon as the +An optional leading icon is picked up automatically - render any icon as the first child and the layout aligns it with the title and description. ## Variants -- `default` — neutral, informational messages. -- `warning` — a non-blocking caution the user should act on (amber). -- `destructive` — an error or a failed action (red). +- `default` - neutral, informational messages. +- `warning` - a non-blocking caution the user should act on (amber). +- `destructive` - an error or a failed action (red). ## Dismissible @@ -41,9 +41,9 @@ Wrap a control in `AlertAction` to pin it to the top-right corner, e.g. a close button. The alert reserves space for it automatically. ```tsx -import { AlertAction } from '@vitnode/core/components/ui/alert'; -import { Button } from '@vitnode/core/components/ui/button'; -import { XIcon } from 'lucide-react'; +import { AlertAction } from "@vitnode/core/components/ui/alert"; +import { Button } from "@vitnode/core/components/ui/button"; +import { XIcon } from "lucide-react"; @@ -59,14 +59,14 @@ import { XIcon } from 'lucide-react'; ## Props -import { TypeTable } from 'fumadocs-ui/components/type-table'; +import { TypeTable } from "fumadocs-ui/components/type-table"; diff --git a/apps/docs/content/docs/ui/auto-form.mdx b/apps/docs/content/docs/ui/auto-form.mdx index f29997f5b..0ea465592 100644 --- a/apps/docs/content/docs/ui/auto-form.mdx +++ b/apps/docs/content/docs/ui/auto-form.mdx @@ -417,6 +417,80 @@ const formSchema = z.object({ /> ``` +## Tabs + +Group fields into tabs by passing the `tabs` prop and tagging each field with a +`tab`. Fields without a `tab` fall into the first tab. Every tab panel stays +mounted, so field values and validation are preserved when switching tabs. + +```tsx + , + }, + { + id: "content", + tab: "content", // [!code ++] + component: props => , + }, + ]} +/> +``` + +## Conditional Fields + +Show or hide a field based on the current form values with the `hidden` +predicate. It receives the live form values and returns `true` to hide the +field. + + + Hidden fields still submit their (default) values, so keep them optional or + give them a `default` in the schema. Otherwise a hidden-but-invalid field can + keep the submit button disabled with no visible error. + + +```ts +const formSchema = z.object({ + allow_uploads: z.boolean().default(false), + // Optional so it never blocks submission while hidden. + max_storage: z.number().int().min(0).nullable().default(null), +}); +``` + +```tsx +import { AutoFormSwitch } from "@vitnode/core/components/form/fields/switch"; +``` + +```tsx + , + }, + { + id: "max_storage", + // Only shown once uploads are enabled. // [!code ++] + hidden: values => !values.allow_uploads, // [!code ++] + component: props => , + }, + ]} +/> +``` + ## Form Submission To activate submit button and handle form submission with the `onSubmit` callback: diff --git a/apps/docs/content/docs/ui/meta.json b/apps/docs/content/docs/ui/meta.json index 3918c4b3a..e9356625b 100644 --- a/apps/docs/content/docs/ui/meta.json +++ b/apps/docs/content/docs/ui/meta.json @@ -20,6 +20,7 @@ "editor", "input", "input-group", + "nullable-number", "radio-group", "select", "switch", diff --git a/apps/docs/content/docs/ui/nullable-number.mdx b/apps/docs/content/docs/ui/nullable-number.mdx new file mode 100644 index 000000000..5cb5b8ca9 --- /dev/null +++ b/apps/docs/content/docs/ui/nullable-number.mdx @@ -0,0 +1,106 @@ +--- +title: Nullable Number +description: Numeric field paired with a checkbox that toggles the value to null - for "unlimited", "never", "no limit", and similar. +--- + +## Preview + + + +## Usage + +```ts +import { z } from "zod"; +import { AutoForm } from "@vitnode/core/components/form/auto-form"; +import { AutoFormNullableNumber } from "@vitnode/core/components/form/fields/nullable-number"; +``` + +```ts +const formSchema = z.object({ + max_members: z.number().int().min(1).nullable().default(10), +}); +``` + +```tsx + ( + + ), + }, + ]} +/> +``` + + + The field value is `number | null`. A number is whatever is typed in the + input; `null` means the checkbox is checked and the input is disabled. Back it + with a `z.number().nullable()` schema, and keep it optional or give it a + `default` when the field can be + [hidden](/docs/ui/auto-form#conditional-fields) so it never blocks submission. + Unchecking the box restores the last number you entered. + + +## Adapting the labels + +The three label props are plain text, so the same field works for any domain - +an unlimited storage cap, a session that never expires, an uncapped rate limit, +and so on: + +- `unitLabel` - shown right after the input (e.g. `kB`, `minutes`, `%`). +- `orLabel` - an optional connector rendered before the checkbox (e.g. `or`). +- `toggleLabel` - the checkbox label; checking it sets the value to `null`. + +```tsx + +``` + +Any other props (`min`, `max`, `step`, `placeholder`, …) are forwarded to the +underlying number input; validation constraints come from the Zod schema. + +## Props + +import { TypeTable } from "fumadocs-ui/components/type-table"; + + diff --git a/apps/docs/migrations/0019_add_role_upload_settings.sql b/apps/docs/migrations/0019_add_role_upload_settings.sql new file mode 100644 index 000000000..6ba443c05 --- /dev/null +++ b/apps/docs/migrations/0019_add_role_upload_settings.sql @@ -0,0 +1,3 @@ +ALTER TABLE "core_roles" ADD COLUMN "allowUploadFiles" boolean DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE "core_roles" ADD COLUMN "totalMaxStorage" integer;--> statement-breakpoint +ALTER TABLE "core_roles" ADD COLUMN "maxStorageForSubmit" integer; \ No newline at end of file diff --git a/apps/docs/migrations/meta/0019_snapshot.json b/apps/docs/migrations/meta/0019_snapshot.json new file mode 100644 index 000000000..ba4a9428f --- /dev/null +++ b/apps/docs/migrations/meta/0019_snapshot.json @@ -0,0 +1,2132 @@ +{ + "id": "ed8aa4e9-a5da-481d-930e-5a8e3d994ec4", + "prevId": "82af3376-1642-4472-9f72-22225d7570e8", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.core_admin_permissions": { + "name": "core_admin_permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "roleId": { + "name": "roleId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "protected": { + "name": "protected", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "unrestricted": { + "name": "unrestricted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + } + }, + "indexes": { + "core_admin_permissions_role_id_idx": { + "name": "core_admin_permissions_role_id_idx", + "columns": [ + { + "expression": "roleId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_admin_permissions_user_id_idx": { + "name": "core_admin_permissions_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_admin_permissions_roleId_core_roles_id_fk": { + "name": "core_admin_permissions_roleId_core_roles_id_fk", + "tableFrom": "core_admin_permissions", + "tableTo": "core_roles", + "columnsFrom": [ + "roleId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "core_admin_permissions_userId_core_users_id_fk": { + "name": "core_admin_permissions_userId_core_users_id_fk", + "tableFrom": "core_admin_permissions", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_admin_sessions": { + "name": "core_admin_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "lastSeen": { + "name": "lastSeen", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "deviceId": { + "name": "deviceId", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "core_admin_sessions_token_idx": { + "name": "core_admin_sessions_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_admin_sessions_user_id_idx": { + "name": "core_admin_sessions_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_admin_sessions_userId_core_users_id_fk": { + "name": "core_admin_sessions_userId_core_users_id_fk", + "tableFrom": "core_admin_sessions", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "core_admin_sessions_deviceId_core_sessions_known_devices_id_fk": { + "name": "core_admin_sessions_deviceId_core_sessions_known_devices_id_fk", + "tableFrom": "core_admin_sessions", + "tableTo": "core_sessions_known_devices", + "columnsFrom": [ + "deviceId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_admin_sessions_token_unique": { + "name": "core_admin_sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_cron": { + "name": "core_cron", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "lastRun": { + "name": "lastRun", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "pluginId": { + "name": "pluginId", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "module": { + "name": "module", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "nextRun": { + "name": "nextRun", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "schedule": { + "name": "schedule", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_files": { + "name": "core_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true + }, + "folder": { + "name": "folder", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "mimeType": { + "name": "mimeType", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "pluginId": { + "name": "pluginId", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "core_files_user_id_idx": { + "name": "core_files_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_files_userId_core_users_id_fk": { + "name": "core_files_userId_core_users_id_fk", + "tableFrom": "core_files", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_files_key_unique": { + "name": "core_files_key_unique", + "nullsNotDistinct": false, + "columns": [ + "key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_languages": { + "name": "core_languages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "code": { + "name": "code", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "protected": { + "name": "protected", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "default": { + "name": "default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time24": { + "name": "time24", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "core_languages_code_idx": { + "name": "core_languages_code_idx", + "columns": [ + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_languages_name_idx": { + "name": "core_languages_name_idx", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_languages_code_unique": { + "name": "core_languages_code_unique", + "nullsNotDistinct": false, + "columns": [ + "code" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_languages_words": { + "name": "core_languages_words", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "languageCode": { + "name": "languageCode", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "pluginCode": { + "name": "pluginCode", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tableName": { + "name": "tableName", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "variable": { + "name": "variable", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "core_languages_words_lang_code_idx": { + "name": "core_languages_words_lang_code_idx", + "columns": [ + { + "expression": "languageCode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_languages_words_languageCode_core_languages_code_fk": { + "name": "core_languages_words_languageCode_core_languages_code_fk", + "tableFrom": "core_languages_words", + "tableTo": "core_languages", + "columnsFrom": [ + "languageCode" + ], + "columnsTo": [ + "code" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_logs": { + "name": "core_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "pluginId": { + "name": "pluginId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ipAddress": { + "name": "ipAddress", + "type": "varchar(45)", + "primaryKey": false, + "notNull": true + }, + "method": { + "name": "method", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "default": "'GET'" + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'localhost'" + }, + "userAgent": { + "name": "userAgent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "statusCode": { + "name": "statusCode", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 500 + }, + "userId": { + "name": "userId", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "test123": { + "name": "test123", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "core_logs_userId_core_users_id_fk": { + "name": "core_logs_userId_core_users_id_fk", + "tableFrom": "core_logs", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_moderators_permissions": { + "name": "core_moderators_permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "roleId": { + "name": "roleId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "protected": { + "name": "protected", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "unrestricted": { + "name": "unrestricted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + } + }, + "indexes": { + "core_moderators_permissions_role_id_idx": { + "name": "core_moderators_permissions_role_id_idx", + "columns": [ + { + "expression": "roleId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_moderators_permissions_user_id_idx": { + "name": "core_moderators_permissions_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_moderators_permissions_roleId_core_roles_id_fk": { + "name": "core_moderators_permissions_roleId_core_roles_id_fk", + "tableFrom": "core_moderators_permissions", + "tableTo": "core_roles", + "columnsFrom": [ + "roleId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "core_moderators_permissions_userId_core_users_id_fk": { + "name": "core_moderators_permissions_userId_core_users_id_fk", + "tableFrom": "core_moderators_permissions", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_queue": { + "name": "core_queue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "pluginId": { + "name": "pluginId", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "queue": { + "name": "queue", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "maxAttempts": { + "name": "maxAttempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "availableAt": { + "name": "availableAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "reservedAt": { + "name": "reservedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "lastError": { + "name": "lastError", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completedAt": { + "name": "completedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "core_queue_status_available_at_idx": { + "name": "core_queue_status_available_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "availableAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_roles": { + "name": "core_roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "protected": { + "name": "protected", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "default": { + "name": "default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "root": { + "name": "root", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "guest": { + "name": "guest", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "color": { + "name": "color", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "allowUploadFiles": { + "name": "allowUploadFiles", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "totalMaxStorage": { + "name": "totalMaxStorage", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "maxStorageForSubmit": { + "name": "maxStorageForSubmit", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_search_index": { + "name": "core_search_index", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "pluginId": { + "name": "pluginId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "itemType": { + "name": "itemType", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "languageCode": { + "name": "languageCode", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "authorId": { + "name": "authorId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "search_vector": { + "name": "search_vector", + "type": "tsvector", + "primaryKey": false, + "notNull": true, + "generated": { + "as": "setweight(to_tsvector(CASE lower(split_part(\"core_search_index\".\"languageCode\", '-', 1)) WHEN 'da' THEN 'danish'::regconfig WHEN 'de' THEN 'german'::regconfig WHEN 'en' THEN 'english'::regconfig WHEN 'es' THEN 'spanish'::regconfig WHEN 'fi' THEN 'finnish'::regconfig WHEN 'fr' THEN 'french'::regconfig WHEN 'hu' THEN 'hungarian'::regconfig WHEN 'it' THEN 'italian'::regconfig WHEN 'nl' THEN 'dutch'::regconfig WHEN 'no' THEN 'norwegian'::regconfig WHEN 'pl' THEN 'polish'::regconfig WHEN 'pt' THEN 'portuguese'::regconfig WHEN 'ro' THEN 'romanian'::regconfig WHEN 'ru' THEN 'russian'::regconfig WHEN 'sv' THEN 'swedish'::regconfig WHEN 'tr' THEN 'turkish'::regconfig ELSE 'simple'::regconfig END, coalesce(\"core_search_index\".\"title\", '')), 'A') || setweight(to_tsvector(CASE lower(split_part(\"core_search_index\".\"languageCode\", '-', 1)) WHEN 'da' THEN 'danish'::regconfig WHEN 'de' THEN 'german'::regconfig WHEN 'en' THEN 'english'::regconfig WHEN 'es' THEN 'spanish'::regconfig WHEN 'fi' THEN 'finnish'::regconfig WHEN 'fr' THEN 'french'::regconfig WHEN 'hu' THEN 'hungarian'::regconfig WHEN 'it' THEN 'italian'::regconfig WHEN 'nl' THEN 'dutch'::regconfig WHEN 'no' THEN 'norwegian'::regconfig WHEN 'pl' THEN 'polish'::regconfig WHEN 'pt' THEN 'portuguese'::regconfig WHEN 'ro' THEN 'romanian'::regconfig WHEN 'ru' THEN 'russian'::regconfig WHEN 'sv' THEN 'swedish'::regconfig WHEN 'tr' THEN 'turkish'::regconfig ELSE 'simple'::regconfig END, coalesce(\"core_search_index\".\"content\", '')), 'B')", + "type": "stored" + } + }, + "containerType": { + "name": "containerType", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "containerId": { + "name": "containerId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "isPublic": { + "name": "isPublic", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "indexedAt": { + "name": "indexedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "core_search_index_search_vector_idx": { + "name": "core_search_index_search_vector_idx", + "columns": [ + { + "expression": "search_vector", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "core_search_index_created_at_idx": { + "name": "core_search_index_created_at_idx", + "columns": [ + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_search_index_author_id_idx": { + "name": "core_search_index_author_id_idx", + "columns": [ + { + "expression": "authorId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_search_index_item_type_idx": { + "name": "core_search_index_item_type_idx", + "columns": [ + { + "expression": "itemType", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_search_index_language_code_idx": { + "name": "core_search_index_language_code_idx", + "columns": [ + { + "expression": "languageCode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_search_index_is_public_idx": { + "name": "core_search_index_is_public_idx", + "columns": [ + { + "expression": "isPublic", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_search_index_authorId_core_users_id_fk": { + "name": "core_search_index_authorId_core_users_id_fk", + "tableFrom": "core_search_index", + "tableTo": "core_users", + "columnsFrom": [ + "authorId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_search_index_item_unique": { + "name": "core_search_index_item_unique", + "nullsNotDistinct": false, + "columns": [ + "itemType", + "itemId", + "languageCode" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_sessions": { + "name": "core_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "deviceId": { + "name": "deviceId", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "core_sessions_user_id_idx": { + "name": "core_sessions_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_sessions_userId_core_users_id_fk": { + "name": "core_sessions_userId_core_users_id_fk", + "tableFrom": "core_sessions", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "core_sessions_deviceId_core_sessions_known_devices_id_fk": { + "name": "core_sessions_deviceId_core_sessions_known_devices_id_fk", + "tableFrom": "core_sessions", + "tableTo": "core_sessions_known_devices", + "columnsFrom": [ + "deviceId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_sessions_token_unique": { + "name": "core_sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_sessions_known_devices": { + "name": "core_sessions_known_devices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "publicId": { + "name": "publicId", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "ipAddress": { + "name": "ipAddress", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true + }, + "userAgent": { + "name": "userAgent", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lastSeen": { + "name": "lastSeen", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "core_sessions_known_devices_ip_address_idx": { + "name": "core_sessions_known_devices_ip_address_idx", + "columns": [ + { + "expression": "ipAddress", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_sessions_known_devices_publicId_unique": { + "name": "core_sessions_known_devices_publicId_unique", + "nullsNotDistinct": false, + "columns": [ + "publicId" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_users": { + "name": "core_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "nameCode": { + "name": "nameCode", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "password": { + "name": "password", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "newsletter": { + "name": "newsletter", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "avatarColor": { + "name": "avatarColor", + "type": "varchar(6)", + "primaryKey": false, + "notNull": true + }, + "emailVerified": { + "name": "emailVerified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "roleId": { + "name": "roleId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "birthday": { + "name": "birthday", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ipAddress": { + "name": "ipAddress", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'en'" + } + }, + "indexes": { + "core_users_name_code_idx": { + "name": "core_users_name_code_idx", + "columns": [ + { + "expression": "nameCode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_users_name_idx": { + "name": "core_users_name_idx", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_users_email_idx": { + "name": "core_users_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_users_roleId_core_roles_id_fk": { + "name": "core_users_roleId_core_roles_id_fk", + "tableFrom": "core_users", + "tableTo": "core_roles", + "columnsFrom": [ + "roleId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "core_users_language_core_languages_code_fk": { + "name": "core_users_language_core_languages_code_fk", + "tableFrom": "core_users", + "tableTo": "core_languages", + "columnsFrom": [ + "language" + ], + "columnsTo": [ + "code" + ], + "onDelete": "set default", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_users_nameCode_unique": { + "name": "core_users_nameCode_unique", + "nullsNotDistinct": false, + "columns": [ + "nameCode" + ] + }, + "core_users_name_unique": { + "name": "core_users_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + }, + "core_users_email_unique": { + "name": "core_users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_users_confirm_emails": { + "name": "core_users_confirm_emails", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ipAddress": { + "name": "ipAddress", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "core_users_confirm_emails_userId_core_users_id_fk": { + "name": "core_users_confirm_emails_userId_core_users_id_fk", + "tableFrom": "core_users_confirm_emails", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_users_confirm_emails_token_unique": { + "name": "core_users_confirm_emails_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_users_forgot_password": { + "name": "core_users_forgot_password", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "ipAddress": { + "name": "ipAddress", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "core_users_forgot_password_userId_core_users_id_fk": { + "name": "core_users_forgot_password_userId_core_users_id_fk", + "tableFrom": "core_users_forgot_password", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_users_forgot_password_userId_unique": { + "name": "core_users_forgot_password_userId_unique", + "nullsNotDistinct": false, + "columns": [ + "userId" + ] + }, + "core_users_forgot_password_token_unique": { + "name": "core_users_forgot_password_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_users_secondary_roles": { + "name": "core_users_secondary_roles", + "schema": "", + "columns": { + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "roleId": { + "name": "roleId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "core_users_secondary_roles_user_id_idx": { + "name": "core_users_secondary_roles_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_users_secondary_roles_role_id_idx": { + "name": "core_users_secondary_roles_role_id_idx", + "columns": [ + { + "expression": "roleId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_users_secondary_roles_userId_core_users_id_fk": { + "name": "core_users_secondary_roles_userId_core_users_id_fk", + "tableFrom": "core_users_secondary_roles", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "core_users_secondary_roles_roleId_core_roles_id_fk": { + "name": "core_users_secondary_roles_roleId_core_roles_id_fk", + "tableFrom": "core_users_secondary_roles", + "tableTo": "core_roles", + "columnsFrom": [ + "roleId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "core_users_secondary_roles_userId_roleId_pk": { + "name": "core_users_secondary_roles_userId_roleId_pk", + "columns": [ + "userId", + "roleId" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_users_sso": { + "name": "core_users_sso", + "schema": "", + "columns": { + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "providerId": { + "name": "providerId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "providerAccountId": { + "name": "providerAccountId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "core_users_sso_user_id_idx": { + "name": "core_users_sso_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_users_sso_userId_core_users_id_fk": { + "name": "core_users_sso_userId_core_users_id_fk", + "tableFrom": "core_users_sso", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.blog_categories": { + "name": "blog_categories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "color": { + "name": "color", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.blog_posts": { + "name": "blog_posts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "categoryId": { + "name": "categoryId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "authorId": { + "name": "authorId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "blog_posts_categoryId_blog_categories_id_fk": { + "name": "blog_posts_categoryId_blog_categories_id_fk", + "tableFrom": "blog_posts", + "tableTo": "blog_categories", + "columnsFrom": [ + "categoryId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "blog_posts_authorId_core_users_id_fk": { + "name": "blog_posts_authorId_core_users_id_fk", + "tableFrom": "blog_posts", + "tableTo": "core_users", + "columnsFrom": [ + "authorId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/docs/migrations/meta/_journal.json b/apps/docs/migrations/meta/_journal.json index ad267fc72..ce1b8b66e 100644 --- a/apps/docs/migrations/meta/_journal.json +++ b/apps/docs/migrations/meta/_journal.json @@ -134,6 +134,13 @@ "when": 1784657968964, "tag": "0018_first_black_crow", "breakpoints": true + }, + { + "idx": 19, + "version": "7", + "when": 1784921474193, + "tag": "0019_add_role_upload_settings", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/docs/src/examples/nullable-number.tsx b/apps/docs/src/examples/nullable-number.tsx new file mode 100644 index 000000000..689fce856 --- /dev/null +++ b/apps/docs/src/examples/nullable-number.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { AutoForm } from "@vitnode/core/components/form/auto-form"; +import { AutoFormNullableNumber } from "@vitnode/core/components/form/fields/nullable-number"; +import { z } from "zod"; + +export default function NullableNumberExample() { + const formSchema = z.object({ + // A concrete number by default -> the input is editable. + max_members: z.number().int().min(1).nullable().default(10), + // `null` by default -> the checkbox is checked and the input disabled. + auto_logout: z.number().int().min(1).nullable().default(null), + }); + + return ( + ( + + ), + }, + { + id: "auto_logout", + component: props => ( + + ), + }, + ]} + formSchema={formSchema} + /> + ); +} diff --git a/apps/docs/src/locales/@vitnode/core/en.json b/apps/docs/src/locales/@vitnode/core/en.json index 4a24efa41..528a31ba2 100644 --- a/apps/docs/src/locales/@vitnode/core/en.json +++ b/apps/docs/src/locales/@vitnode/core/en.json @@ -568,9 +568,22 @@ "submit": "Save changes", "success": "Role updated" }, + "tabs": { + "general": "General", + "content": "Content" + }, "form": { "name": "Name", - "color": "Color" + "color": "Color", + "upload": { + "allow": "Allow upload files", + "total_max_storage": "Total Max Storage", + "max_storage_for_submit": "Max Storage for Submit", + "max_storage_for_submit_desc": "If you set 1000, user can upload only 1 file with size 1000kB or 2 files with size 500kB for each submit.", + "in_unit": "in kB", + "or": "or", + "unlimited": "Unlimited" + } } }, "staff": { diff --git a/apps/docs/src/locales/@vitnode/core/pl.json b/apps/docs/src/locales/@vitnode/core/pl.json index 57a425b80..2087987d6 100644 --- a/apps/docs/src/locales/@vitnode/core/pl.json +++ b/apps/docs/src/locales/@vitnode/core/pl.json @@ -610,7 +610,7 @@ "redis": { "title": "Redis", "desc": "Współdzielona pamięć podręczna i magazyn ogranicznika żądań. Przy wyłączeniu przechodzi na pamięć wewnętrzną.", - "down": "Skonfigurowany, ale nieosiągalny — sprawdź połączenie z Redis." + "down": "Skonfigurowany, ale nieosiągalny - sprawdź połączenie z Redis." }, "email": { "title": "E-mail", @@ -655,8 +655,8 @@ "desc": "Kliknij, aby wybrać obraz", "pending": "Przesyłanie…", "done": "Przesłano", - "error": "Przesyłanie nie powiodło się — spróbuj ponownie", - "success": "Obraz został przesłany — jest przechowywany w Twoim adapterze." + "error": "Przesyłanie nie powiodło się - spróbuj ponownie", + "success": "Obraz został przesłany - jest przechowywany w Twoim adapterze." } } }, @@ -671,9 +671,9 @@ "cron": { "title": "Zadania cron", "desc": "Zaplanowane zadania w tle, takie jak czyszczenie wygasłych sesji i tokenów.", - "insecure": "Używany jest domyślny CRON_SECRET — ustaw bezpieczny w środowisku produkcyjnym.", - "not_configured": "Nie skonfigurowano adaptera cron — zadania nie będą uruchamiać się automatycznie.", - "stale": "Żadne zadanie nie zostało uruchomione od ponad 6 godzin — cron może być źle skonfigurowany lub zatrzymany. Sprawdź konfigurację cron.", + "insecure": "Używany jest domyślny CRON_SECRET - ustaw bezpieczny w środowisku produkcyjnym.", + "not_configured": "Nie skonfigurowano adaptera cron - zadania nie będą uruchamiać się automatycznie.", + "stale": "Żadne zadanie nie zostało uruchomione od ponad 6 godzin - cron może być źle skonfigurowany lub zatrzymany. Sprawdź konfigurację cron.", "jobs": "{count, plural, =0 {Brak zaplanowanych zadań} one {# zaplanowane zadanie} few {# zaplanowane zadania} many {# zaplanowanych zadań} other {# zaplanowanych zadań}}" }, "queue": { @@ -681,7 +681,7 @@ "desc": "Zadania w tle umieszczone w kolejce w bazie danych i przetwarzane przez proces cron.", "tasks": "{count, plural, =0 {Brak zarejestrowanych zadań} one {# zarejestrowane zadanie} few {# zarejestrowane zadania} many {# zarejestrowanych zadań} other {# zarejestrowanych zadań}}", "queued": "{pending} oczekujących · {processing} uruchomionych", - "cron_stale": "Offline — proces cron przetwarzający kolejkę nie działa. Napraw zadania cron, aby wznowić przetwarzanie." + "cron_stale": "Offline - proces cron przetwarzający kolejkę nie działa. Napraw zadania cron, aby wznowić przetwarzanie." } }, "files": { diff --git a/packages/vitnode/scripts/prepare-database.ts b/packages/vitnode/scripts/prepare-database.ts index 3714d1a96..057abec26 100644 --- a/packages/vitnode/scripts/prepare-database.ts +++ b/packages/vitnode/scripts/prepare-database.ts @@ -193,12 +193,14 @@ export const initialDataForDatabase = async () => { // Moderator role protected: true, color: "hsl(122, 80%, 45%)", + allowUploadFiles: true, }, { // Administrator role protected: true, root: true, color: "hsl(0, 100%, 50%)", + allowUploadFiles: true, }, ]) .returning({ id: core_roles.id }); diff --git a/packages/vitnode/src/api/modules/admin/debug/routes/search-status.route.ts b/packages/vitnode/src/api/modules/admin/debug/routes/search-status.route.ts index d83109b3c..629145a46 100644 --- a/packages/vitnode/src/api/modules/admin/debug/routes/search-status.route.ts +++ b/packages/vitnode/src/api/modules/admin/debug/routes/search-status.route.ts @@ -45,7 +45,7 @@ export const searchStatusDebugAdminRoute = buildRoute({ const core = c.get("core"); // One item can emit several index rows (e.g. one per language), so coverage - // is measured in distinct items — not documents. + // is measured in distinct items - not documents. const indexedByType = await db .select({ itemType: core_search_index.itemType, diff --git a/packages/vitnode/src/api/modules/admin/roles/routes/create.route.ts b/packages/vitnode/src/api/modules/admin/roles/routes/create.route.ts index 3a1dcd468..8cc1395e6 100644 --- a/packages/vitnode/src/api/modules/admin/roles/routes/create.route.ts +++ b/packages/vitnode/src/api/modules/admin/roles/routes/create.route.ts @@ -16,9 +16,15 @@ export const zodRoleNameSchema = z ) .min(1); +// Storage caps are expressed in kB. `null` means unlimited. +export const zodRoleStorageSchema = z.number().int().min(0).nullable(); + export const zodCreateRoleAdminSchema = z.object({ name: zodRoleNameSchema, color: z.string().max(50).optional(), + allowUploadFiles: z.boolean().optional(), + totalMaxStorage: zodRoleStorageSchema.optional(), + maxStorageForSubmit: zodRoleStorageSchema.optional(), }); export const createRoleAdminRoute = buildRoute({ @@ -52,12 +58,24 @@ export const createRoleAdminRoute = buildRoute({ }, }, handler: async c => { - const { name, color } = c.req.valid("json"); + const { + name, + color, + allowUploadFiles, + totalMaxStorage, + maxStorageForSubmit, + } = c.req.valid("json"); const [role] = await c .get("db") .insert(core_roles) - .values({ color: color?.trim() ? color : null, updatedAt: new Date() }) + .values({ + color: color?.trim() ? color : null, + allowUploadFiles: allowUploadFiles ?? false, + totalMaxStorage: totalMaxStorage ?? null, + maxStorageForSubmit: maxStorageForSubmit ?? null, + updatedAt: new Date(), + }) .returning({ id: core_roles.id }); await saveLanguageWords(c, { diff --git a/packages/vitnode/src/api/modules/admin/roles/routes/list.route.ts b/packages/vitnode/src/api/modules/admin/roles/routes/list.route.ts index 53aa420b1..04ca95361 100644 --- a/packages/vitnode/src/api/modules/admin/roles/routes/list.route.ts +++ b/packages/vitnode/src/api/modules/admin/roles/routes/list.route.ts @@ -29,6 +29,9 @@ const rolesAdminListSchema = z.object({ default: z.boolean(), root: z.boolean(), guest: z.boolean(), + allowUploadFiles: z.boolean(), + totalMaxStorage: z.number().nullable(), + maxStorageForSubmit: z.number().nullable(), createdAt: z.date(), updatedAt: z.date(), usersCount: z.number(), @@ -102,6 +105,9 @@ export const listRolesAdminRoute = buildRoute({ default: core_roles.default, root: core_roles.root, guest: core_roles.guest, + allowUploadFiles: core_roles.allowUploadFiles, + totalMaxStorage: core_roles.totalMaxStorage, + maxStorageForSubmit: core_roles.maxStorageForSubmit, createdAt: core_roles.createdAt, updatedAt: core_roles.updatedAt, }) diff --git a/packages/vitnode/src/api/modules/admin/roles/routes/show.route.ts b/packages/vitnode/src/api/modules/admin/roles/routes/show.route.ts index ff0826456..ae6ab395e 100644 --- a/packages/vitnode/src/api/modules/admin/roles/routes/show.route.ts +++ b/packages/vitnode/src/api/modules/admin/roles/routes/show.route.ts @@ -21,6 +21,9 @@ const roleAdminSchema = z.object({ default: z.boolean(), root: z.boolean(), guest: z.boolean(), + allowUploadFiles: z.boolean(), + totalMaxStorage: z.number().nullable(), + maxStorageForSubmit: z.number().nullable(), createdAt: z.date(), updatedAt: z.date(), }); @@ -76,6 +79,9 @@ export const showRoleAdminRoute = buildRoute({ default: core_roles.default, root: core_roles.root, guest: core_roles.guest, + allowUploadFiles: core_roles.allowUploadFiles, + totalMaxStorage: core_roles.totalMaxStorage, + maxStorageForSubmit: core_roles.maxStorageForSubmit, createdAt: core_roles.createdAt, }) .from(core_roles) diff --git a/packages/vitnode/src/api/modules/admin/roles/routes/update.route.ts b/packages/vitnode/src/api/modules/admin/roles/routes/update.route.ts index 456da7bb5..fdd97f745 100644 --- a/packages/vitnode/src/api/modules/admin/roles/routes/update.route.ts +++ b/packages/vitnode/src/api/modules/admin/roles/routes/update.route.ts @@ -6,12 +6,15 @@ import { saveLanguageWords } from "@/api/lib/save-language-words"; import { CONFIG_PLUGIN } from "@/config"; import { core_roles } from "@/database/roles"; -import { zodRoleNameSchema } from "./create.route"; +import { zodRoleNameSchema, zodRoleStorageSchema } from "./create.route"; export const zodUpdateRoleAdminSchema = z .object({ name: zodRoleNameSchema, color: z.string().max(50), + allowUploadFiles: z.boolean(), + totalMaxStorage: zodRoleStorageSchema, + maxStorageForSubmit: zodRoleStorageSchema, }) .partial() .refine(body => Object.values(body).some(value => value !== undefined), { @@ -85,6 +88,16 @@ export const updateRoleAdminRoute = buildRoute({ if (body.color !== undefined) { values.color = body.color.trim() ? body.color : null; } + if (body.allowUploadFiles !== undefined) { + values.allowUploadFiles = body.allowUploadFiles; + } + // `null` is a meaningful value here (unlimited), so only skip `undefined`. + if (body.totalMaxStorage !== undefined) { + values.totalMaxStorage = body.totalMaxStorage; + } + if (body.maxStorageForSubmit !== undefined) { + values.maxStorageForSubmit = body.maxStorageForSubmit; + } await db.update(core_roles).set(values).where(eq(core_roles.id, roleId)); diff --git a/packages/vitnode/src/components/form/auto-form.tsx b/packages/vitnode/src/components/form/auto-form.tsx index a21ebb566..0b8ff9770 100644 --- a/packages/vitnode/src/components/form/auto-form.tsx +++ b/packages/vitnode/src/components/form/auto-form.tsx @@ -28,21 +28,34 @@ import { Button } from "../ui/button"; import { DialogClose, DialogFooter, useDialog } from "../ui/dialog"; import { Field } from "../ui/field"; import { Form, FormField } from "../ui/form"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs"; + +interface ItemAutoFormSharedProps> { + hidden?: (values: z.input) => boolean; + tab?: string; +} type ItemAutoFormProps< T extends z.ZodObject = z.ZodObject, TName extends FieldPath> = FieldPath>, -> = - | { - component: (props: ItemAutoFormComponentProps) => React.ReactNode; - id: TName; - } - | { - component?: never; - description?: React.ReactNode; - id: TName; - label?: React.ReactNode; - }; +> = ItemAutoFormSharedProps & + ( + | { + component: (props: ItemAutoFormComponentProps) => React.ReactNode; + id: TName; + } + | { + component?: never; + description?: React.ReactNode; + id: TName; + label?: React.ReactNode; + } + ); + +export interface AutoFormTab { + label: React.ReactNode; + value: string; +} export interface ItemAutoFormComponentProps { description?: React.ReactNode; @@ -105,6 +118,7 @@ export function AutoForm< onSubmit: onSubmitProp, captcha, fields, + tabs, submitButtonProps, children, ...props @@ -118,6 +132,7 @@ export function AutoForm< React.ComponentProps, "isLoading" | "type" >; + tabs?: AutoFormTab[]; }) { const { isReady, @@ -147,6 +162,100 @@ export function AutoForm< } }; + const hasConditionalFields = fields.some( + item => typeof item.hidden === "function", + ); + // Only subscribe to value changes when a field actually needs them, so forms + // without conditional fields keep their previous (non re-rendering) behavior. + // The subscription-driven re-render is intentional here. + // eslint-disable-next-line react-hooks/incompatible-library + const watchedValues = hasConditionalFields ? form.watch() : undefined; + const isFieldVisible = (item: ItemAutoFormProps) => { + if (!item.hidden || !watchedValues) return true; + + return !item.hidden(watchedValues); + }; + + const renderField = (item: ItemAutoFormProps) => { + const params = getNestedParam(inputParams, item.id); + if (!params) return null; + + if (!item.component && (item.label || item.description)) { + return ( + + {item.label && ( + + {item.label} + + )} + {item.description && ( + + {item.description} + + )} + + ); + } + + if (!item.component) return null; + const { component } = item; + + return ( + { + return ( + + {component({ + field, + description: + typeof params.description === "string" + ? params.description + : "", + itemParams: + "itemParams" in params + ? (params.itemParams as InputParams) + : undefined, + otherProps: { + isOptional: !params.required, + enum: Array.isArray(params.enum) ? params.enum : undefined, + maxLength: + typeof params.maxLength === "number" + ? params.maxLength + : undefined, + maxItems: + typeof params.maxItems === "number" + ? params.maxItems + : undefined, + minLength: + typeof params.minLength === "number" + ? params.minLength + : undefined, + ["aria-invalid"]: fieldState.invalid, + minItems: + typeof params.minItems === "number" + ? params.minItems + : undefined, + pattern: + typeof params.pattern === "string" + ? params.pattern + : undefined, + type: + typeof params.type === "string" ? params.type : undefined, + }, + })} + + ); + }} + /> + ); + }; + const submitButton = ( - {fields.map(item => { - const params = getNestedParam(inputParams, item.id); - if (!params) return null; - - if (!item.component && (item.label || item.description)) { - return ( - - {item.label && ( - - {item.label} - - )} - {item.description && ( - - {item.description} - - )} - - ); - } - - if (!item.component) return null; + {tabs?.length ? ( + + + {tabs.map(tab => ( + + {tab.label} + + ))} + - return ( - { - return ( - - {item.component({ - field, - description: - typeof params.description === "string" - ? params.description - : "", - itemParams: - "itemParams" in params - ? (params.itemParams as InputParams) - : undefined, - otherProps: { - isOptional: !params.required, - enum: Array.isArray(params.enum) - ? params.enum - : undefined, - maxLength: - typeof params.maxLength === "number" - ? params.maxLength - : undefined, - maxItems: - typeof params.maxItems === "number" - ? params.maxItems - : undefined, - minLength: - typeof params.minLength === "number" - ? params.minLength - : undefined, - ["aria-invalid"]: fieldState.invalid, - minItems: - typeof params.minItems === "number" - ? params.minItems - : undefined, - pattern: - typeof params.pattern === "string" - ? params.pattern - : undefined, - type: - typeof params.type === "string" - ? params.type - : undefined, - }, - })} - - ); - }} - /> - ); - })} + {tabs.map(tab => ( + + {fields + .filter(item => (item.tab ?? tabs[0].value) === tab.value) + .filter(isFieldVisible) + .map(renderField)} + + ))} + + ) : ( + fields.filter(isFieldVisible).map(renderField) + )} {children} diff --git a/packages/vitnode/src/components/form/fields/nullable-number.tsx b/packages/vitnode/src/components/form/fields/nullable-number.tsx new file mode 100644 index 000000000..a235cd5ee --- /dev/null +++ b/packages/vitnode/src/components/form/fields/nullable-number.tsx @@ -0,0 +1,135 @@ +import React from "react"; + +import { Checkbox } from "@/components/ui/checkbox"; +import { FormControl, FormMessage } from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { cn } from "@/lib/utils"; + +import type { ItemAutoFormComponentProps } from "../auto-form"; + +import { AutoFormDesc } from "../common/desc"; +import { AutoFormLabel } from "../common/label"; + +type AutoFormNullableNumberProps = ItemAutoFormComponentProps & + Omit, "value"> & { + // Optional connector text between the unit and the checkbox, e.g. "or". + orLabel?: React.ReactNode; + // Label for the checkbox. Checking it sets the value to `null` and disables + // the input (e.g. "Unlimited", "Never", "No limit"). + toggleLabel: React.ReactNode; + // Optional unit shown right after the input, e.g. "kB", "minutes", "%". + unitLabel?: React.ReactNode; + }; + +/** + * A numeric field paired with a checkbox that toggles the value to `null`. + * + * The form value is `number | null`: + * - a `number` - the value typed in the input, or + * - `null` - when the checkbox is checked (the input is disabled). + * + * Use it wherever a number can also mean "no value": an unlimited storage cap, + * a session that never expires, an uncapped rate limit, and so on. Back it with + * a `z.number().nullable()` schema (keep it optional/defaulted when the field + * can be hidden). Extra props (`min`, `max`, `step`, `placeholder`, …) are + * forwarded to the underlying input, and validation constraints come from the + * Zod schema. + * + * @example + * ```tsx + * const formSchema = z.object({ + * maxMembers: z.number().int().min(1).nullable().default(null), + * }); + * + * + * ``` + */ +export const AutoFormNullableNumber = ({ + label, + labelRight, + description, + field, + otherProps: { isOptional }, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + itemParams, + className, + unitLabel, + orLabel, + toggleLabel, + ...props +}: AutoFormNullableNumberProps) => { + const isToggled = field.value === null; + const [text, setText] = React.useState( + typeof field.value === "number" ? String(field.value) : "", + ); + // Remember the last numeric value so unchecking the toggle restores it. + const lastNumericRef = React.useRef( + typeof field.value === "number" ? field.value : 0, + ); + + return ( + <> + {label && ( + + {label} + + )} + + + + { + const raw = event.target.value; + setText(raw); + + if (raw === "") { + lastNumericRef.current = 0; + field.onChange(0); + + return; + } + + const parsed = Number(raw); + if (Number.isNaN(parsed)) return; + + lastNumericRef.current = parsed; + field.onChange(parsed); + }} + type="number" + value={isToggled ? "" : text} + {...props} + /> + + + {unitLabel && ( + {unitLabel} + )} + {orLabel && ( + {orLabel} + )} + + + { + if (checked) { + field.onChange(null); + + return; + } + + field.onChange(lastNumericRef.current); + setText(String(lastNumericRef.current)); + }} + /> + {toggleLabel} + + + + {description && {description}} + + > + ); +}; diff --git a/packages/vitnode/src/database/roles.ts b/packages/vitnode/src/database/roles.ts index 7c8dcb859..78cfd3675 100644 --- a/packages/vitnode/src/database/roles.ts +++ b/packages/vitnode/src/database/roles.ts @@ -12,4 +12,7 @@ export const core_roles = pgTable("core_roles", t => ({ root: t.boolean().notNull().default(false), guest: t.boolean().notNull().default(false), color: t.varchar({ length: 50 }), + allowUploadFiles: t.boolean().notNull().default(false), + totalMaxStorage: t.integer(), + maxStorageForSubmit: t.integer(), })).enableRLS(); diff --git a/packages/vitnode/src/locales/en.json b/packages/vitnode/src/locales/en.json index 4a24efa41..528a31ba2 100644 --- a/packages/vitnode/src/locales/en.json +++ b/packages/vitnode/src/locales/en.json @@ -568,9 +568,22 @@ "submit": "Save changes", "success": "Role updated" }, + "tabs": { + "general": "General", + "content": "Content" + }, "form": { "name": "Name", - "color": "Color" + "color": "Color", + "upload": { + "allow": "Allow upload files", + "total_max_storage": "Total Max Storage", + "max_storage_for_submit": "Max Storage for Submit", + "max_storage_for_submit_desc": "If you set 1000, user can upload only 1 file with size 1000kB or 2 files with size 500kB for each submit.", + "in_unit": "in kB", + "or": "or", + "unlimited": "Unlimited" + } } }, "staff": { diff --git a/packages/vitnode/src/views/admin/views/core/advanced/search/collection-status.ts b/packages/vitnode/src/views/admin/views/core/advanced/search/collection-status.ts index 80a0bd96d..eb9a75628 100644 --- a/packages/vitnode/src/views/admin/views/core/advanced/search/collection-status.ts +++ b/packages/vitnode/src/views/admin/views/core/advanced/search/collection-status.ts @@ -9,7 +9,7 @@ export interface SearchCollection { export type CollectionStatus = "empty" | "indexed" | "stale"; // Nothing indexed yet, partially indexed (fewer items than the source has), or -// fully covered — the three states the coverage report distinguishes. +// fully covered - the three states the coverage report distinguishes. export const getCollectionStatus = ({ indexed, total, diff --git a/packages/vitnode/src/views/admin/views/core/users/roles/actions/create-edit/create-edit.test.tsx b/packages/vitnode/src/views/admin/views/core/users/roles/actions/create-edit/create-edit.test.tsx index 5df5c4ac8..2060a983c 100644 --- a/packages/vitnode/src/views/admin/views/core/users/roles/actions/create-edit/create-edit.test.tsx +++ b/packages/vitnode/src/views/admin/views/core/users/roles/actions/create-edit/create-edit.test.tsx @@ -60,9 +60,12 @@ describe("CreateEditRoleAdmin", () => { expect(screen.getByText("form.color")).toBeDefined(); // The multiLang language select (only shown with > 1 language). expect(screen.getByRole("combobox")).toBeDefined(); + // General/Content tabs. + expect(screen.getByRole("tab", { name: "tabs.general" })).toBeDefined(); + expect(screen.getByRole("tab", { name: "tabs.content" })).toBeDefined(); }); - it("submits the name as a { languageCode, value }[] array on create", async () => { + it("submits the name plus default upload settings on create", async () => { renderForm(); const [nameInput] = screen.getAllByRole("textbox"); @@ -74,6 +77,9 @@ describe("CreateEditRoleAdmin", () => { expect(createRole).toHaveBeenCalledWith({ name: [{ languageCode: "en", value: "Administrator" }], color: "", + allowUploadFiles: false, + totalMaxStorage: null, + maxStorageForSubmit: null, }); }); expect(editRole).not.toHaveBeenCalled(); @@ -87,6 +93,9 @@ describe("CreateEditRoleAdmin", () => { { languageCode: "en", name: "Admin" }, { languageCode: "pl", name: "Administrator" }, ], + allowUploadFiles: false, + totalMaxStorage: null, + maxStorageForSubmit: null, }); // The name is a text input; the color renders on the color-picker trigger. @@ -104,8 +113,64 @@ describe("CreateEditRoleAdmin", () => { { languageCode: "pl", value: "Administrator" }, ], color: "#ef4444", + allowUploadFiles: false, + totalMaxStorage: null, + maxStorageForSubmit: null, }); }); expect(createRole).not.toHaveBeenCalled(); }); + + it("reveals the storage fields only after enabling uploads", async () => { + renderForm(); + + fireEvent.click(screen.getByRole("tab", { name: "tabs.content" })); + + // Hidden until "allow upload files" is on. + expect(screen.queryByText("form.upload.total_max_storage")).toBeNull(); + + fireEvent.click(screen.getByRole("switch")); + + expect( + await screen.findByText("form.upload.total_max_storage"), + ).toBeDefined(); + expect( + screen.getByText("form.upload.max_storage_for_submit"), + ).toBeDefined(); + }); + + it("stores a numeric cap once 'unlimited' is unchecked and submits it", async () => { + renderForm(); + + // Fill the required name while the General tab is active. + const [nameInput] = screen.getAllByRole("textbox"); + fireEvent.change(nameInput, { target: { value: "Uploader" } }); + + fireEvent.click(screen.getByRole("tab", { name: "tabs.content" })); + fireEvent.click(screen.getByRole("switch")); + + // Storage defaults to unlimited (null) -> input disabled, checkbox checked. + const [totalInput] = + await screen.findAllByRole("spinbutton"); + expect(totalInput.disabled).toBe(true); + + // Uncheck "unlimited" for the total cap -> the input becomes editable. + const [totalUnlimited] = screen.getAllByRole("checkbox"); + fireEvent.click(totalUnlimited); + expect(totalInput.disabled).toBe(false); + + fireEvent.change(totalInput, { target: { value: "500000" } }); + + submitForm(totalInput); + + await waitFor(() => { + expect(createRole).toHaveBeenCalledWith({ + name: [{ languageCode: "en", value: "Uploader" }], + color: "", + allowUploadFiles: true, + totalMaxStorage: 500000, + maxStorageForSubmit: null, + }); + }); + }); }); diff --git a/packages/vitnode/src/views/admin/views/core/users/roles/actions/create-edit/create-edit.tsx b/packages/vitnode/src/views/admin/views/core/users/roles/actions/create-edit/create-edit.tsx index ece114b70..523f23805 100644 --- a/packages/vitnode/src/views/admin/views/core/users/roles/actions/create-edit/create-edit.tsx +++ b/packages/vitnode/src/views/admin/views/core/users/roles/actions/create-edit/create-edit.tsx @@ -5,6 +5,8 @@ import { z } from "zod"; import { AutoForm, type AutoFormOnSubmit } from "@/components/form/auto-form"; import { AutoFormColor } from "@/components/form/fields/color"; import { AutoFormInput } from "@/components/form/fields/input"; +import { AutoFormNullableNumber } from "@/components/form/fields/nullable-number"; +import { AutoFormSwitch } from "@/components/form/fields/switch"; import { useDialog } from "@/components/ui/dialog"; import { multiLangValueSchema } from "@/lib/helpers/multi-lang"; import { usePathname, useRouter } from "@/lib/navigation"; @@ -12,9 +14,12 @@ import { usePathname, useRouter } from "@/lib/navigation"; import { createRole, editRole } from "./mutation-api.server"; export interface RoleData { + allowUploadFiles: boolean; color: null | string; id: number; + maxStorageForSubmit: null | number; name: { languageCode: string; name: string }[]; + totalMaxStorage: null | number; } export const CreateEditRoleAdmin = ({ data }: { data?: RoleData }) => { @@ -37,6 +42,21 @@ export const CreateEditRoleAdmin = ({ data }: { data?: RoleData }) => { .string() .max(50) .default(data?.color ?? ""), + allowUploadFiles: z.boolean().default(data?.allowUploadFiles ?? false), + // `null` means unlimited. Values are stored in kB. + totalMaxStorage: z + .number() + .int() + .min(0) + .nullable() + .default(data?.totalMaxStorage ?? null), + maxStorageForSubmit: z + .number() + .int() + .min(0) + .nullable() + .default(data?.maxStorageForSubmit ?? null) + .describe(t("form.upload.max_storage_for_submit_desc")), }); const onSubmit: AutoFormOnSubmit = async values => { @@ -62,12 +82,14 @@ export const CreateEditRoleAdmin = ({ data }: { data?: RoleData }) => { fields={[ { id: "name", + tab: "general", component: props => ( ), }, { id: "color", + tab: "general", component: props => ( { /> ), }, + { + id: "allowUploadFiles", + tab: "content", + component: props => ( + + ), + }, + { + id: "totalMaxStorage", + tab: "content", + hidden: values => !values.allowUploadFiles, + component: props => ( + + ), + }, + { + id: "maxStorageForSubmit", + tab: "content", + hidden: values => !values.allowUploadFiles, + component: props => ( + + ), + }, ]} formSchema={formSchema} onSubmit={onSubmit} submitButtonProps={{ children: t(`${data ? "edit" : "create"}.submit`), }} + tabs={[ + { value: "general", label: t("tabs.general") }, + { value: "content", label: t("tabs.content") }, + ]} /> ); };