feat: add board access settings (#249)
* wip: add board access settings * wip: add user access control * wip: add user access control * feat: add user access control * refactor: move away from mantine-modal-manager * fix: ci issues and failing tests * fix: lint issue * fix: format issue * fix: deepsource issues * chore: address pull request feedback
This commit is contained in:
@@ -4,6 +4,7 @@ import superjson from "superjson";
|
||||
import type { Database, SQL } from "@homarr/db";
|
||||
import { and, createId, eq, inArray } from "@homarr/db";
|
||||
import {
|
||||
boardPermissions,
|
||||
boards,
|
||||
integrationItems,
|
||||
items,
|
||||
@@ -309,6 +310,52 @@ export const boardRouter = createTRPCRouter({
|
||||
}
|
||||
});
|
||||
}),
|
||||
|
||||
permissions: publicProcedure
|
||||
.input(validation.board.permissions)
|
||||
.query(async ({ input, ctx }) => {
|
||||
const permissions = await ctx.db.query.boardPermissions.findMany({
|
||||
where: eq(boardPermissions.boardId, input.id),
|
||||
with: {
|
||||
user: {
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
return permissions
|
||||
.map((permission) => ({
|
||||
user: {
|
||||
id: permission.userId,
|
||||
name: permission.user.name ?? "",
|
||||
},
|
||||
permission: permission.permission,
|
||||
}))
|
||||
.sort((permissionA, permissionB) => {
|
||||
return permissionA.user.name.localeCompare(permissionB.user.name);
|
||||
});
|
||||
}),
|
||||
savePermissions: publicProcedure
|
||||
.input(validation.board.savePermissions)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
await ctx.db.transaction(async (tx) => {
|
||||
await tx
|
||||
.delete(boardPermissions)
|
||||
.where(eq(boardPermissions.boardId, input.id));
|
||||
if (input.permissions.length === 0) {
|
||||
return;
|
||||
}
|
||||
await tx.insert(boardPermissions).values(
|
||||
input.permissions.map((permission) => ({
|
||||
userId: permission.user.id,
|
||||
permission: permission.permission,
|
||||
boardId: input.id,
|
||||
})),
|
||||
);
|
||||
});
|
||||
}),
|
||||
});
|
||||
|
||||
const noBoardWithSimilarName = async (
|
||||
@@ -341,6 +388,12 @@ const getFullBoardWithWhere = async (db: Database, where: SQL<unknown>) => {
|
||||
const board = await db.query.boards.findFirst({
|
||||
where,
|
||||
with: {
|
||||
creator: {
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
sections: {
|
||||
with: {
|
||||
items: {
|
||||
|
||||
@@ -44,6 +44,14 @@ export const userRouter = createTRPCRouter({
|
||||
},
|
||||
});
|
||||
}),
|
||||
selectable: publicProcedure.query(async ({ ctx }) => {
|
||||
return await ctx.db.query.users.findMany({
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
});
|
||||
}),
|
||||
getById: publicProcedure
|
||||
.input(z.object({ userId: z.string() }))
|
||||
.query(async ({ input, ctx }) => {
|
||||
|
||||
@@ -54,12 +54,12 @@ const createAdapter = () => {
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
||||
type SessionExport = typeof import("../session");
|
||||
const mockSessionToken = "e9ef3010-6981-4a81-b9d6-8495d09cf3b5" as const;
|
||||
const mockSessionToken = "e9ef3010-6981-4a81-b9d6-8495d09cf3b5";
|
||||
const mockSessionExpiry = new Date("2023-07-01");
|
||||
vi.mock("../session", async (importOriginal) => {
|
||||
const mod = await importOriginal<SessionExport>();
|
||||
|
||||
const generateSessionToken = () => mockSessionToken;
|
||||
const generateSessionToken = (): typeof mockSessionToken => mockSessionToken;
|
||||
const expireDateAfter = (_seconds: number) => mockSessionExpiry;
|
||||
|
||||
return {
|
||||
|
||||
@@ -14,10 +14,28 @@ CREATE TABLE `account` (
|
||||
FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `app` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`description` text,
|
||||
`icon_url` text NOT NULL,
|
||||
`href` text
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `boardPermission` (
|
||||
`board_id` text NOT NULL,
|
||||
`user_id` text NOT NULL,
|
||||
`permission` text NOT NULL,
|
||||
PRIMARY KEY(`board_id`, `permission`, `user_id`),
|
||||
FOREIGN KEY (`board_id`) REFERENCES `board`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `board` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`is_public` integer DEFAULT false NOT NULL,
|
||||
`creator_id` text,
|
||||
`page_title` text,
|
||||
`meta_title` text,
|
||||
`logo_image_url` text,
|
||||
@@ -30,7 +48,8 @@ CREATE TABLE `board` (
|
||||
`secondary_color` text DEFAULT '#fd7e14' NOT NULL,
|
||||
`opacity` integer DEFAULT 100 NOT NULL,
|
||||
`custom_css` text,
|
||||
`column_count` integer DEFAULT 10 NOT NULL
|
||||
`column_count` integer DEFAULT 10 NOT NULL,
|
||||
FOREIGN KEY (`creator_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE set null
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `integration_item` (
|
||||
@@ -1,7 +0,0 @@
|
||||
CREATE TABLE `app` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`description` text,
|
||||
`icon_url` text NOT NULL,
|
||||
`href` text
|
||||
);
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"version": "5",
|
||||
"dialect": "sqlite",
|
||||
"id": "7ba12cbf-d8eb-4469-b7c5-7f31acb7717d",
|
||||
"id": "7c2291ee-febd-4b90-994c-85e6ef27102d",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"tables": {
|
||||
"account": {
|
||||
@@ -111,6 +111,104 @@
|
||||
},
|
||||
"uniqueConstraints": {}
|
||||
},
|
||||
"app": {
|
||||
"name": "app",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"icon_url": {
|
||||
"name": "icon_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"href": {
|
||||
"name": "href",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {}
|
||||
},
|
||||
"boardPermission": {
|
||||
"name": "boardPermission",
|
||||
"columns": {
|
||||
"board_id": {
|
||||
"name": "board_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"permission": {
|
||||
"name": "permission",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"boardPermission_board_id_board_id_fk": {
|
||||
"name": "boardPermission_board_id_board_id_fk",
|
||||
"tableFrom": "boardPermission",
|
||||
"tableTo": "board",
|
||||
"columnsFrom": ["board_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"boardPermission_user_id_user_id_fk": {
|
||||
"name": "boardPermission_user_id_user_id_fk",
|
||||
"tableFrom": "boardPermission",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": ["user_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"boardPermission_board_id_user_id_permission_pk": {
|
||||
"columns": ["board_id", "permission", "user_id"],
|
||||
"name": "boardPermission_board_id_user_id_permission_pk"
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {}
|
||||
},
|
||||
"board": {
|
||||
"name": "board",
|
||||
"columns": {
|
||||
@@ -136,6 +234,13 @@
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"creator_id": {
|
||||
"name": "creator_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"page_title": {
|
||||
"name": "page_title",
|
||||
"type": "text",
|
||||
@@ -242,7 +347,17 @@
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"foreignKeys": {
|
||||
"board_creator_id_user_id_fk": {
|
||||
"name": "board_creator_id_user_id_fk",
|
||||
"tableFrom": "board",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": ["creator_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {}
|
||||
},
|
||||
|
||||
@@ -1,722 +0,0 @@
|
||||
{
|
||||
"version": "5",
|
||||
"dialect": "sqlite",
|
||||
"id": "f7263224-116a-42ba-8fb1-4574cb637880",
|
||||
"prevId": "7ba12cbf-d8eb-4469-b7c5-7f31acb7717d",
|
||||
"tables": {
|
||||
"account": {
|
||||
"name": "account",
|
||||
"columns": {
|
||||
"userId": {
|
||||
"name": "userId",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"provider": {
|
||||
"name": "provider",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"providerAccountId": {
|
||||
"name": "providerAccountId",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"refresh_token": {
|
||||
"name": "refresh_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"access_token": {
|
||||
"name": "access_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"token_type": {
|
||||
"name": "token_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"scope": {
|
||||
"name": "scope",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"id_token": {
|
||||
"name": "id_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"session_state": {
|
||||
"name": "session_state",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"userId_idx": {
|
||||
"name": "userId_idx",
|
||||
"columns": ["userId"],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"account_userId_user_id_fk": {
|
||||
"name": "account_userId_user_id_fk",
|
||||
"tableFrom": "account",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": ["userId"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"account_provider_providerAccountId_pk": {
|
||||
"columns": ["provider", "providerAccountId"],
|
||||
"name": "account_provider_providerAccountId_pk"
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {}
|
||||
},
|
||||
"app": {
|
||||
"name": "app",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"icon_url": {
|
||||
"name": "icon_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"href": {
|
||||
"name": "href",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {}
|
||||
},
|
||||
"board": {
|
||||
"name": "board",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"is_public": {
|
||||
"name": "is_public",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"page_title": {
|
||||
"name": "page_title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"meta_title": {
|
||||
"name": "meta_title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"logo_image_url": {
|
||||
"name": "logo_image_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"favicon_image_url": {
|
||||
"name": "favicon_image_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"background_image_url": {
|
||||
"name": "background_image_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"background_image_attachment": {
|
||||
"name": "background_image_attachment",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'fixed'"
|
||||
},
|
||||
"background_image_repeat": {
|
||||
"name": "background_image_repeat",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'no-repeat'"
|
||||
},
|
||||
"background_image_size": {
|
||||
"name": "background_image_size",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'cover'"
|
||||
},
|
||||
"primary_color": {
|
||||
"name": "primary_color",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'#fa5252'"
|
||||
},
|
||||
"secondary_color": {
|
||||
"name": "secondary_color",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'#fd7e14'"
|
||||
},
|
||||
"opacity": {
|
||||
"name": "opacity",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 100
|
||||
},
|
||||
"custom_css": {
|
||||
"name": "custom_css",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"column_count": {
|
||||
"name": "column_count",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 10
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"board_name_unique": {
|
||||
"name": "board_name_unique",
|
||||
"columns": ["name"],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {}
|
||||
},
|
||||
"integration_item": {
|
||||
"name": "integration_item",
|
||||
"columns": {
|
||||
"item_id": {
|
||||
"name": "item_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"integration_id": {
|
||||
"name": "integration_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"integration_item_item_id_item_id_fk": {
|
||||
"name": "integration_item_item_id_item_id_fk",
|
||||
"tableFrom": "integration_item",
|
||||
"tableTo": "item",
|
||||
"columnsFrom": ["item_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"integration_item_integration_id_integration_id_fk": {
|
||||
"name": "integration_item_integration_id_integration_id_fk",
|
||||
"tableFrom": "integration_item",
|
||||
"tableTo": "integration",
|
||||
"columnsFrom": ["integration_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"integration_item_item_id_integration_id_pk": {
|
||||
"columns": ["integration_id", "item_id"],
|
||||
"name": "integration_item_item_id_integration_id_pk"
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {}
|
||||
},
|
||||
"integrationSecret": {
|
||||
"name": "integrationSecret",
|
||||
"columns": {
|
||||
"kind": {
|
||||
"name": "kind",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"value": {
|
||||
"name": "value",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"integration_id": {
|
||||
"name": "integration_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"integration_secret__kind_idx": {
|
||||
"name": "integration_secret__kind_idx",
|
||||
"columns": ["kind"],
|
||||
"isUnique": false
|
||||
},
|
||||
"integration_secret__updated_at_idx": {
|
||||
"name": "integration_secret__updated_at_idx",
|
||||
"columns": ["updated_at"],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"integrationSecret_integration_id_integration_id_fk": {
|
||||
"name": "integrationSecret_integration_id_integration_id_fk",
|
||||
"tableFrom": "integrationSecret",
|
||||
"tableTo": "integration",
|
||||
"columnsFrom": ["integration_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"integrationSecret_integration_id_kind_pk": {
|
||||
"columns": ["integration_id", "kind"],
|
||||
"name": "integrationSecret_integration_id_kind_pk"
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {}
|
||||
},
|
||||
"integration": {
|
||||
"name": "integration",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"url": {
|
||||
"name": "url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"kind": {
|
||||
"name": "kind",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"integration__kind_idx": {
|
||||
"name": "integration__kind_idx",
|
||||
"columns": ["kind"],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {}
|
||||
},
|
||||
"item": {
|
||||
"name": "item",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"section_id": {
|
||||
"name": "section_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"kind": {
|
||||
"name": "kind",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"x_offset": {
|
||||
"name": "x_offset",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"y_offset": {
|
||||
"name": "y_offset",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"width": {
|
||||
"name": "width",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"height": {
|
||||
"name": "height",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"options": {
|
||||
"name": "options",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'{\"json\": {}}'"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"item_section_id_section_id_fk": {
|
||||
"name": "item_section_id_section_id_fk",
|
||||
"tableFrom": "item",
|
||||
"tableTo": "section",
|
||||
"columnsFrom": ["section_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {}
|
||||
},
|
||||
"section": {
|
||||
"name": "section",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"board_id": {
|
||||
"name": "board_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"kind": {
|
||||
"name": "kind",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"position": {
|
||||
"name": "position",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"section_board_id_board_id_fk": {
|
||||
"name": "section_board_id_board_id_fk",
|
||||
"tableFrom": "section",
|
||||
"tableTo": "board",
|
||||
"columnsFrom": ["board_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {}
|
||||
},
|
||||
"session": {
|
||||
"name": "session",
|
||||
"columns": {
|
||||
"sessionToken": {
|
||||
"name": "sessionToken",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"userId": {
|
||||
"name": "userId",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"expires": {
|
||||
"name": "expires",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"user_id_idx": {
|
||||
"name": "user_id_idx",
|
||||
"columns": ["userId"],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"session_userId_user_id_fk": {
|
||||
"name": "session_userId_user_id_fk",
|
||||
"tableFrom": "session",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": ["userId"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {}
|
||||
},
|
||||
"user": {
|
||||
"name": "user",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"emailVerified": {
|
||||
"name": "emailVerified",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"image": {
|
||||
"name": "image",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"password": {
|
||||
"name": "password",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"salt": {
|
||||
"name": "salt",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {}
|
||||
},
|
||||
"verificationToken": {
|
||||
"name": "verificationToken",
|
||||
"columns": {
|
||||
"identifier": {
|
||||
"name": "identifier",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"token": {
|
||||
"name": "token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"expires": {
|
||||
"name": "expires",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"verificationToken_identifier_token_pk": {
|
||||
"columns": ["identifier", "token"],
|
||||
"name": "verificationToken_identifier_token_pk"
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {}
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
}
|
||||
}
|
||||
@@ -5,15 +5,8 @@
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "5",
|
||||
"when": 1709409142712,
|
||||
"tag": "0000_sloppy_bloodstorm",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "5",
|
||||
"when": 1709585624230,
|
||||
"tag": "0001_slim_swarm",
|
||||
"when": 1710878250235,
|
||||
"tag": "0000_productive_changeling",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
BackgroundImageAttachment,
|
||||
BackgroundImageRepeat,
|
||||
BackgroundImageSize,
|
||||
BoardPermission,
|
||||
IntegrationKind,
|
||||
IntegrationSecretKind,
|
||||
SectionKind,
|
||||
@@ -97,8 +98,8 @@ export const integrations = mysqlTable(
|
||||
url: text("url").notNull(),
|
||||
kind: varchar("kind", { length: 128 }).$type<IntegrationKind>().notNull(),
|
||||
},
|
||||
(i) => ({
|
||||
kindIdx: index("integration__kind_idx").on(i.kind),
|
||||
(integrations) => ({
|
||||
kindIdx: index("integration__kind_idx").on(integrations.kind),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -127,6 +128,9 @@ export const boards = mysqlTable("board", {
|
||||
id: varchar("id", { length: 256 }).notNull().primaryKey(),
|
||||
name: varchar("name", { length: 256 }).unique().notNull(),
|
||||
isPublic: boolean("is_public").default(false).notNull(),
|
||||
creatorId: text("creator_id").references(() => users.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
pageTitle: text("page_title"),
|
||||
metaTitle: text("meta_title"),
|
||||
logoImageUrl: text("logo_image_url"),
|
||||
@@ -151,6 +155,24 @@ export const boards = mysqlTable("board", {
|
||||
columnCount: int("column_count").default(10).notNull(),
|
||||
});
|
||||
|
||||
export const boardPermissions = mysqlTable(
|
||||
"boardPermission",
|
||||
{
|
||||
boardId: text("board_id")
|
||||
.notNull()
|
||||
.references(() => boards.id, { onDelete: "cascade" }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
permission: text("permission").$type<BoardPermission>().notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
compoundKey: primaryKey({
|
||||
columns: [table.boardId, table.userId, table.permission],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
export const sections = mysqlTable("section", {
|
||||
id: varchar("id", { length: 256 }).notNull().primaryKey(),
|
||||
boardId: varchar("board_id", { length: 256 })
|
||||
@@ -208,8 +230,25 @@ export const accountRelations = relations(accounts, ({ one }) => ({
|
||||
|
||||
export const userRelations = relations(users, ({ many }) => ({
|
||||
accounts: many(accounts),
|
||||
|
||||
boards: many(boards),
|
||||
boardPermissions: many(boardPermissions),
|
||||
}));
|
||||
|
||||
export const boardPermissionRelations = relations(
|
||||
boardPermissions,
|
||||
({ one }) => ({
|
||||
user: one(users, {
|
||||
fields: [boardPermissions.userId],
|
||||
references: [users.id],
|
||||
}),
|
||||
board: one(boards, {
|
||||
fields: [boardPermissions.boardId],
|
||||
references: [boards.id],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
export const integrationRelations = relations(integrations, ({ many }) => ({
|
||||
secrets: many(integrationSecrets),
|
||||
items: many(integrationItems),
|
||||
@@ -225,8 +264,13 @@ export const integrationSecretRelations = relations(
|
||||
}),
|
||||
);
|
||||
|
||||
export const boardRelations = relations(boards, ({ many }) => ({
|
||||
export const boardRelations = relations(boards, ({ many, one }) => ({
|
||||
sections: many(sections),
|
||||
creator: one(users, {
|
||||
fields: [boards.creatorId],
|
||||
references: [users.id],
|
||||
}),
|
||||
permissions: many(boardPermissions),
|
||||
}));
|
||||
|
||||
export const sectionRelations = relations(sections, ({ many, one }) => ({
|
||||
|
||||
@@ -19,6 +19,7 @@ import type {
|
||||
BackgroundImageAttachment,
|
||||
BackgroundImageRepeat,
|
||||
BackgroundImageSize,
|
||||
BoardPermission,
|
||||
IntegrationKind,
|
||||
IntegrationSecretKind,
|
||||
SectionKind,
|
||||
@@ -94,8 +95,8 @@ export const integrations = sqliteTable(
|
||||
url: text("url").notNull(),
|
||||
kind: text("kind").$type<IntegrationKind>().notNull(),
|
||||
},
|
||||
(i) => ({
|
||||
kindIdx: index("integration__kind_idx").on(i.kind),
|
||||
(integrations) => ({
|
||||
kindIdx: index("integration__kind_idx").on(integrations.kind),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -122,6 +123,9 @@ export const boards = sqliteTable("board", {
|
||||
id: text("id").notNull().primaryKey(),
|
||||
name: text("name").unique().notNull(),
|
||||
isPublic: int("is_public", { mode: "boolean" }).default(false).notNull(),
|
||||
creatorId: text("creator_id").references(() => users.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
pageTitle: text("page_title"),
|
||||
metaTitle: text("meta_title"),
|
||||
logoImageUrl: text("logo_image_url"),
|
||||
@@ -146,6 +150,24 @@ export const boards = sqliteTable("board", {
|
||||
columnCount: int("column_count").default(10).notNull(),
|
||||
});
|
||||
|
||||
export const boardPermissions = sqliteTable(
|
||||
"boardPermission",
|
||||
{
|
||||
boardId: text("board_id")
|
||||
.notNull()
|
||||
.references(() => boards.id, { onDelete: "cascade" }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
permission: text("permission").$type<BoardPermission>().notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
compoundKey: primaryKey({
|
||||
columns: [table.boardId, table.userId, table.permission],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
export const sections = sqliteTable("section", {
|
||||
id: text("id").notNull().primaryKey(),
|
||||
boardId: text("board_id")
|
||||
@@ -203,8 +225,24 @@ export const accountRelations = relations(accounts, ({ one }) => ({
|
||||
|
||||
export const userRelations = relations(users, ({ many }) => ({
|
||||
accounts: many(accounts),
|
||||
boards: many(boards),
|
||||
boardPermissions: many(boardPermissions),
|
||||
}));
|
||||
|
||||
export const boardPermissionRelations = relations(
|
||||
boardPermissions,
|
||||
({ one }) => ({
|
||||
user: one(users, {
|
||||
fields: [boardPermissions.userId],
|
||||
references: [users.id],
|
||||
}),
|
||||
board: one(boards, {
|
||||
fields: [boardPermissions.boardId],
|
||||
references: [boards.id],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
export const integrationRelations = relations(integrations, ({ many }) => ({
|
||||
secrets: many(integrationSecrets),
|
||||
items: many(integrationItems),
|
||||
@@ -220,8 +258,13 @@ export const integrationSecretRelations = relations(
|
||||
}),
|
||||
);
|
||||
|
||||
export const boardRelations = relations(boards, ({ many }) => ({
|
||||
export const boardRelations = relations(boards, ({ many, one }) => ({
|
||||
sections: many(sections),
|
||||
creator: one(users, {
|
||||
fields: [boards.creatorId],
|
||||
references: [users.id],
|
||||
}),
|
||||
permissions: many(boardPermissions),
|
||||
}));
|
||||
|
||||
export const sectionRelations = relations(sections, ({ many, one }) => ({
|
||||
|
||||
@@ -51,7 +51,7 @@ test("schemas should match", () => {
|
||||
},
|
||||
);
|
||||
|
||||
const mysqlTable = mysqlSchema[tableName as keyof typeof mysqlSchema];
|
||||
const mysqlTable = mysqlSchema[tableName];
|
||||
const sqliteForeignKeys = sqliteTable[
|
||||
Symbol.for("drizzle:SQLiteInlineForeignKeys") as keyof typeof sqliteTable
|
||||
] as SqliteForeignKey[] | undefined;
|
||||
@@ -97,7 +97,9 @@ test("schemas should match", () => {
|
||||
|
||||
sqliteForeignKey.reference().foreignColumns.forEach((column) => {
|
||||
expect(
|
||||
mysqlForeignKey!.reference().foreignColumns.map((x) => x.name),
|
||||
mysqlForeignKey!
|
||||
.reference()
|
||||
.foreignColumns.map((column) => column.name),
|
||||
`expect foreign key (${sqliteForeignKey.getName()}) columns to be the same for both schemas`,
|
||||
).toContainEqual(column.name);
|
||||
});
|
||||
|
||||
@@ -2,3 +2,4 @@ export * from "./board";
|
||||
export * from "./integration";
|
||||
export * from "./section";
|
||||
export * from "./widget";
|
||||
export * from "./permissions";
|
||||
|
||||
3
packages/definitions/src/permissions.ts
Normal file
3
packages/definitions/src/permissions.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const boardPermissions = ["board-view", "board-change"] as const;
|
||||
|
||||
export type BoardPermission = (typeof boardPermissions)[number];
|
||||
2
packages/modals/index.ts
Normal file
2
packages/modals/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { ModalProvider, useModalAction, useConfirmModal } from "./src";
|
||||
export { createModal } from "./src/creator";
|
||||
39
packages/modals/package.json
Normal file
39
packages/modals/package.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "@homarr/modals",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"exports": {
|
||||
".": "./index.ts"
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
"*": [
|
||||
"src/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"clean": "rm -rf .turbo node_modules",
|
||||
"lint": "eslint .",
|
||||
"format": "prettier --check . --ignore-path ../../.gitignore",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@homarr/ui": "workspace:^0.1.0",
|
||||
"@homarr/translation": "workspace:^0.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^8.57.0",
|
||||
"typescript": "^5.4.2"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"@homarr/eslint-config/base"
|
||||
]
|
||||
},
|
||||
"prettier": "@homarr/prettier-config"
|
||||
}
|
||||
92
packages/modals/src/confirm-modal.tsx
Normal file
92
packages/modals/src/confirm-modal.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { useCallback } from "react";
|
||||
import type { ComponentPropsWithoutRef, ReactNode } from "react";
|
||||
|
||||
import type {
|
||||
stringOrTranslation,
|
||||
TranslationFunction,
|
||||
} from "@homarr/translation";
|
||||
import { translateIfNecessary } from "@homarr/translation";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import type { ButtonProps, GroupProps } from "@homarr/ui";
|
||||
import { Box, Button, Group } from "@homarr/ui";
|
||||
|
||||
import { createModal } from "./creator";
|
||||
|
||||
type MaybePromise<T> = T | Promise<T>;
|
||||
|
||||
export interface ConfirmModalProps {
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
onConfirm?: () => MaybePromise<void>;
|
||||
onCancel?: () => MaybePromise<void>;
|
||||
closeOnConfirm?: boolean;
|
||||
closeOnCancel?: boolean;
|
||||
cancelProps?: ButtonProps & ComponentPropsWithoutRef<"button">;
|
||||
confirmProps?: ButtonProps & ComponentPropsWithoutRef<"button">;
|
||||
groupProps?: GroupProps;
|
||||
|
||||
labels?: {
|
||||
confirm?: stringOrTranslation;
|
||||
cancel?: stringOrTranslation;
|
||||
};
|
||||
}
|
||||
|
||||
export const ConfirmModal = createModal<Omit<ConfirmModalProps, "title">>(
|
||||
({ actions, innerProps }) => {
|
||||
const t = useI18n();
|
||||
const {
|
||||
children,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
cancelProps,
|
||||
confirmProps,
|
||||
groupProps,
|
||||
labels,
|
||||
} = innerProps;
|
||||
|
||||
const closeOnConfirm = innerProps.closeOnConfirm ?? true;
|
||||
const closeOnCancel = innerProps.closeOnCancel ?? true;
|
||||
|
||||
const cancelLabel =
|
||||
labels?.cancel ?? ((t: TranslationFunction) => t("common.action.cancel"));
|
||||
const confirmLabel =
|
||||
labels?.confirm ??
|
||||
((t: TranslationFunction) => t("common.action.confirm"));
|
||||
|
||||
const handleCancel = useCallback(
|
||||
async (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
typeof cancelProps?.onClick === "function" &&
|
||||
cancelProps?.onClick(event);
|
||||
typeof onCancel === "function" && (await onCancel());
|
||||
closeOnCancel && actions.closeModal();
|
||||
},
|
||||
[cancelProps?.onClick, onCancel, actions.closeModal],
|
||||
);
|
||||
|
||||
const handleConfirm = useCallback(
|
||||
async (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
typeof confirmProps?.onClick === "function" &&
|
||||
confirmProps?.onClick(event);
|
||||
typeof onConfirm === "function" && (await onConfirm());
|
||||
closeOnConfirm && actions.closeModal();
|
||||
},
|
||||
[confirmProps?.onClick, onConfirm, actions.closeModal],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{children && <Box mb="md">{children}</Box>}
|
||||
|
||||
<Group justify="flex-end" {...groupProps}>
|
||||
<Button variant="default" {...cancelProps} onClick={handleCancel}>
|
||||
{cancelProps?.children || translateIfNecessary(t, cancelLabel)}
|
||||
</Button>
|
||||
|
||||
<Button {...confirmProps} onClick={handleConfirm} color="red.9">
|
||||
{confirmProps?.children || translateIfNecessary(t, confirmLabel)}
|
||||
</Button>
|
||||
</Group>
|
||||
</>
|
||||
);
|
||||
},
|
||||
).withOptions({});
|
||||
14
packages/modals/src/creator.ts
Normal file
14
packages/modals/src/creator.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { CreateModalOptions, ModalComponent } from "./type";
|
||||
|
||||
export const createModal = <TInnerProps>(
|
||||
component: ModalComponent<TInnerProps>,
|
||||
) => {
|
||||
return {
|
||||
withOptions: (options: Partial<CreateModalOptions>) => {
|
||||
return {
|
||||
component,
|
||||
options,
|
||||
};
|
||||
},
|
||||
};
|
||||
};
|
||||
141
packages/modals/src/index.tsx
Normal file
141
packages/modals/src/index.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useReducer,
|
||||
useRef,
|
||||
} from "react";
|
||||
import { randomId } from "@mantine/hooks";
|
||||
|
||||
import type { stringOrTranslation } from "@homarr/translation";
|
||||
import { translateIfNecessary } from "@homarr/translation";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { getDefaultZIndex, Modal } from "@homarr/ui";
|
||||
|
||||
import type { ConfirmModalProps } from "./confirm-modal";
|
||||
import { ConfirmModal } from "./confirm-modal";
|
||||
import { modalReducer } from "./reducer";
|
||||
import type { inferInnerProps, ModalDefinition } from "./type";
|
||||
|
||||
interface ModalContextProps {
|
||||
openModalInner: <TModal extends ModalDefinition>(props: {
|
||||
modal: TModal;
|
||||
innerProps: inferInnerProps<TModal>;
|
||||
options: OpenModalOptions;
|
||||
}) => void;
|
||||
closeModal: (id: string) => void;
|
||||
}
|
||||
|
||||
export const ModalContext = createContext<ModalContextProps | null>(null);
|
||||
|
||||
export const ModalProvider = ({ children }: PropsWithChildren) => {
|
||||
const t = useI18n();
|
||||
const [state, dispatch] = useReducer(modalReducer, {
|
||||
modals: [],
|
||||
current: null,
|
||||
});
|
||||
const stateRef = useRef(state);
|
||||
stateRef.current = state;
|
||||
|
||||
const closeModal = useCallback(
|
||||
(id: string, canceled?: boolean) => {
|
||||
dispatch({ type: "CLOSE", modalId: id, canceled });
|
||||
},
|
||||
[stateRef, dispatch],
|
||||
);
|
||||
|
||||
const openModalInner: ModalContextProps["openModalInner"] = useCallback(
|
||||
({ modal, innerProps, options }) => {
|
||||
const id = randomId();
|
||||
const { title, ...rest } = options;
|
||||
dispatch({
|
||||
type: "OPEN",
|
||||
modal: {
|
||||
id,
|
||||
modal,
|
||||
props: {
|
||||
...modal.options,
|
||||
...rest,
|
||||
defaultTitle: title ?? modal.options.defaultTitle,
|
||||
innerProps,
|
||||
},
|
||||
},
|
||||
});
|
||||
return id;
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
const handleCloseModal = useCallback(
|
||||
() => state.current && closeModal(state.current.id),
|
||||
[closeModal, state.current?.id],
|
||||
);
|
||||
|
||||
const activeModals = state.modals.filter(
|
||||
(modal) => modal.id === state.current?.id || modal.props.keepMounted,
|
||||
);
|
||||
|
||||
return (
|
||||
<ModalContext.Provider value={{ openModalInner, closeModal }}>
|
||||
{activeModals.map((modal) => (
|
||||
<Modal
|
||||
key={modal.id}
|
||||
zIndex={getDefaultZIndex("modal") + 1}
|
||||
display={modal.id === state.current?.id ? undefined : "none"}
|
||||
style={{
|
||||
userSelect: modal.id === state.current?.id ? undefined : "none",
|
||||
}}
|
||||
styles={{
|
||||
title: {
|
||||
fontSize: "1.25rem",
|
||||
fontWeight: 500,
|
||||
},
|
||||
}}
|
||||
trapFocus={modal.id === state.current?.id}
|
||||
{...modal.reference.modalProps}
|
||||
title={translateIfNecessary(t, modal.props.defaultTitle)}
|
||||
opened={state.modals.length > 0}
|
||||
onClose={handleCloseModal}
|
||||
>
|
||||
{modal.reference.content}
|
||||
</Modal>
|
||||
))}
|
||||
|
||||
{children}
|
||||
</ModalContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
interface OpenModalOptions {
|
||||
keepMounted?: boolean;
|
||||
title?: stringOrTranslation;
|
||||
}
|
||||
|
||||
export const useModalAction = <TModal extends ModalDefinition>(
|
||||
modal: TModal,
|
||||
) => {
|
||||
const context = useContext(ModalContext);
|
||||
|
||||
if (!context) throw new Error("ModalContext is not provided");
|
||||
|
||||
return {
|
||||
openModal: (
|
||||
innerProps: inferInnerProps<TModal>,
|
||||
options: OpenModalOptions | void,
|
||||
) => {
|
||||
context.openModalInner({ modal, innerProps, options: options ?? {} });
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const useConfirmModal = () => {
|
||||
const { openModal } = useModalAction(ConfirmModal);
|
||||
|
||||
return {
|
||||
openConfirmModal: (props: ConfirmModalProps) =>
|
||||
openModal(props, { title: props.title }),
|
||||
};
|
||||
};
|
||||
125
packages/modals/src/reducer.tsx
Normal file
125
packages/modals/src/reducer.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
"use client";
|
||||
|
||||
import { useContext } from "react";
|
||||
|
||||
import { ModalContext } from ".";
|
||||
import type { ModalDefinition, ModalState } from "./type";
|
||||
|
||||
type ModalStateWithReference = ModalState & {
|
||||
/**
|
||||
* Reference to modal component instance
|
||||
* Used so the modal can be persisted between navigating in newer modals
|
||||
*/
|
||||
reference: ReturnType<typeof getModal>;
|
||||
};
|
||||
|
||||
interface ModalsState {
|
||||
modals: ModalStateWithReference[];
|
||||
|
||||
/**
|
||||
* Modal that is currently open or was the last open one.
|
||||
* Keeping the last one is necessary for providing a clean exit transition.
|
||||
*/
|
||||
current: ModalStateWithReference | null;
|
||||
}
|
||||
|
||||
interface OpenAction {
|
||||
type: "OPEN";
|
||||
modal: ModalState;
|
||||
}
|
||||
|
||||
interface CloseAction {
|
||||
type: "CLOSE";
|
||||
modalId: string;
|
||||
canceled?: boolean;
|
||||
}
|
||||
|
||||
interface CloseAllAction {
|
||||
type: "CLOSE_ALL";
|
||||
canceled?: boolean;
|
||||
}
|
||||
|
||||
export const modalReducer = (
|
||||
state: ModalsState,
|
||||
action: OpenAction | CloseAction | CloseAllAction,
|
||||
): ModalsState => {
|
||||
switch (action.type) {
|
||||
case "OPEN": {
|
||||
const newModal = {
|
||||
...action.modal,
|
||||
reference: getModal(action.modal),
|
||||
};
|
||||
return {
|
||||
current: newModal,
|
||||
modals: [...state.modals, newModal],
|
||||
};
|
||||
}
|
||||
case "CLOSE": {
|
||||
const modal = state.modals.find((modal) => modal.id === action.modalId);
|
||||
if (!modal) {
|
||||
return state;
|
||||
}
|
||||
|
||||
modal.props.onClose?.();
|
||||
|
||||
const remainingModals = state.modals.filter(
|
||||
(modal) => modal.id !== action.modalId,
|
||||
);
|
||||
|
||||
return {
|
||||
current: remainingModals[remainingModals.length - 1] || state.current,
|
||||
modals: remainingModals,
|
||||
};
|
||||
}
|
||||
case "CLOSE_ALL": {
|
||||
if (!state.modals.length) {
|
||||
return state;
|
||||
}
|
||||
|
||||
// Resolve modal stack from top to bottom
|
||||
state.modals
|
||||
.concat()
|
||||
.reverse()
|
||||
.forEach((modal) => {
|
||||
modal.props.onClose?.();
|
||||
});
|
||||
|
||||
return {
|
||||
current: state.current,
|
||||
modals: [],
|
||||
};
|
||||
}
|
||||
default: {
|
||||
return state;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getModal = <TModal extends ModalDefinition>(
|
||||
modal: ModalState<TModal>,
|
||||
) => {
|
||||
const ModalContent = modal.modal.component;
|
||||
|
||||
const { innerProps, ...rest } = modal.props;
|
||||
const FullModal = () => {
|
||||
const context = useContext(ModalContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("Modal component used outside of modal context");
|
||||
}
|
||||
|
||||
return (
|
||||
<ModalContent
|
||||
innerProps={innerProps}
|
||||
actions={{
|
||||
closeModal: () => context.closeModal(modal.id),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
modalProps: rest,
|
||||
content: <FullModal />,
|
||||
};
|
||||
};
|
||||
43
packages/modals/src/type.ts
Normal file
43
packages/modals/src/type.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import type { stringOrTranslation } from "@homarr/translation";
|
||||
import type { ModalProps } from "@homarr/ui";
|
||||
|
||||
export type ModalComponent<TInnerProps> = (props: {
|
||||
actions: { closeModal: () => void };
|
||||
innerProps: TInnerProps;
|
||||
}) => ReactNode;
|
||||
|
||||
export type CreateModalOptions = Pick<
|
||||
ModalOptions<unknown>,
|
||||
| "size"
|
||||
| "fullScreen"
|
||||
| "centered"
|
||||
| "keepMounted"
|
||||
| "withCloseButton"
|
||||
| "zIndex"
|
||||
| "scrollAreaComponent"
|
||||
| "yOffset"
|
||||
> & {
|
||||
defaultTitle: stringOrTranslation;
|
||||
};
|
||||
|
||||
export interface ModalDefinition {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
component: ModalComponent<any>;
|
||||
options: Partial<CreateModalOptions>;
|
||||
}
|
||||
|
||||
type ModalOptions<TInnerProps> = Partial<Omit<ModalProps, "opened">> & {
|
||||
innerProps: TInnerProps;
|
||||
defaultTitle?: stringOrTranslation;
|
||||
};
|
||||
|
||||
export interface ModalState<TModal extends ModalDefinition = ModalDefinition> {
|
||||
id: string;
|
||||
modal: TModal;
|
||||
props: ModalOptions<inferInnerProps<TModal>>;
|
||||
}
|
||||
|
||||
export type inferInnerProps<TModal extends ModalDefinition> =
|
||||
TModal["component"] extends ModalComponent<infer P> ? P : never;
|
||||
8
packages/modals/tsconfig.json
Normal file
8
packages/modals/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@homarr/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
|
||||
},
|
||||
"include": ["*.ts", "*.tsx", "src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { stringOrTranslation, TranslationFunction } from "./type";
|
||||
|
||||
export * from "./type";
|
||||
|
||||
export const supportedLanguages = ["en", "de"] as const;
|
||||
@@ -5,3 +7,13 @@ export type SupportedLanguage = (typeof supportedLanguages)[number];
|
||||
|
||||
export const defaultLocale = "en";
|
||||
export { languageMapping } from "./lang";
|
||||
|
||||
export const translateIfNecessary = (
|
||||
t: TranslationFunction,
|
||||
value: stringOrTranslation | undefined,
|
||||
) => {
|
||||
if (typeof value === "function") {
|
||||
return value(t);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
@@ -191,9 +191,11 @@ export default {
|
||||
},
|
||||
common: {
|
||||
action: {
|
||||
add: "Add",
|
||||
backToOverview: "Back to overview",
|
||||
create: "Create",
|
||||
edit: "Edit",
|
||||
remove: "Remove",
|
||||
save: "Save",
|
||||
saveChanges: "Save changes",
|
||||
cancel: "Cancel",
|
||||
@@ -512,6 +514,35 @@ export default {
|
||||
customCss: {
|
||||
title: "Custom css",
|
||||
},
|
||||
access: {
|
||||
title: "Access control",
|
||||
permission: {
|
||||
userSelect: {
|
||||
title: "Add user permission",
|
||||
label: "Select user",
|
||||
notFound: "No user found",
|
||||
},
|
||||
field: {
|
||||
user: {
|
||||
label: "User",
|
||||
},
|
||||
permission: {
|
||||
label: "Permission",
|
||||
},
|
||||
},
|
||||
item: {
|
||||
"board-view": {
|
||||
label: "View board",
|
||||
},
|
||||
"board-change": {
|
||||
label: "Change board",
|
||||
},
|
||||
"board-full": {
|
||||
label: "Full access",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
dangerZone: {
|
||||
title: "Danger Zone",
|
||||
action: {
|
||||
|
||||
@@ -3,3 +3,4 @@ import type enTranslation from "./lang/en";
|
||||
|
||||
export type TranslationFunction = ReturnType<typeof useI18n>;
|
||||
export type TranslationObject = typeof enTranslation;
|
||||
export type stringOrTranslation = string | ((t: TranslationFunction) => string);
|
||||
|
||||
@@ -4,8 +4,10 @@ import {
|
||||
backgroundImageAttachments,
|
||||
backgroundImageRepeats,
|
||||
backgroundImageSizes,
|
||||
boardPermissions,
|
||||
} from "@homarr/definitions";
|
||||
|
||||
import { zodEnumFromArray } from "./enums";
|
||||
import { commonItemSchema, createSectionSchema } from "./shared";
|
||||
|
||||
const hexColorSchema = z.string().regex(/^#[0-9A-Fa-f]{6}$/);
|
||||
@@ -65,6 +67,23 @@ const saveSchema = z.object({
|
||||
|
||||
const createSchema = z.object({ name: boardNameSchema });
|
||||
|
||||
const permissionsSchema = z.object({
|
||||
id: z.string(),
|
||||
});
|
||||
|
||||
const savePermissionsSchema = z.object({
|
||||
id: z.string(),
|
||||
permissions: z.array(
|
||||
z.object({
|
||||
user: z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
}),
|
||||
permission: zodEnumFromArray(boardPermissions),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
export const boardSchemas = {
|
||||
byName: byNameSchema,
|
||||
savePartialSettings: savePartialSettingsSchema,
|
||||
@@ -72,4 +91,6 @@ export const boardSchemas = {
|
||||
create: createSchema,
|
||||
rename: renameSchema,
|
||||
changeVisibility: changeVisibilitySchema,
|
||||
permissions: permissionsSchema,
|
||||
savePermissions: savePermissionsSchema,
|
||||
};
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
"@homarr/definitions": "workspace:^0.1.0",
|
||||
"@homarr/form": "workspace:^0.1.0",
|
||||
"@homarr/api": "workspace:^0.1.0",
|
||||
"@homarr/modals": "workspace:^0.1.0",
|
||||
"@homarr/notifications": "workspace:^0.1.0",
|
||||
"@homarr/translation": "workspace:^0.1.0",
|
||||
"@homarr/ui": "workspace:^0.1.0",
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import type { ManagedModal } from "mantine-modal-manager";
|
||||
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import { createModal } from "@homarr/modals";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { Button, Group, Stack } from "@homarr/ui";
|
||||
|
||||
@@ -26,61 +25,67 @@ interface ModalProps<TSort extends WidgetKind> {
|
||||
integrationSupport: boolean;
|
||||
}
|
||||
|
||||
export const WidgetEditModal: ManagedModal<ModalProps<WidgetKind>> = ({
|
||||
actions,
|
||||
innerProps,
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
const form = useForm({
|
||||
initialValues: innerProps.value,
|
||||
});
|
||||
export const WidgetEditModal = createModal<ModalProps<WidgetKind>>(
|
||||
({ actions, innerProps }) => {
|
||||
const t = useI18n();
|
||||
const form = useForm({
|
||||
initialValues: innerProps.value,
|
||||
});
|
||||
|
||||
const { definition } = widgetImports[innerProps.kind];
|
||||
const { definition } = widgetImports[innerProps.kind];
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={form.onSubmit((v) => {
|
||||
innerProps.onSuccessfulEdit(v);
|
||||
actions.closeModal();
|
||||
})}
|
||||
>
|
||||
<FormProvider form={form}>
|
||||
<Stack>
|
||||
{innerProps.integrationSupport && (
|
||||
<WidgetIntegrationSelect
|
||||
label={t("item.edit.field.integrations.label")}
|
||||
data={innerProps.integrationData}
|
||||
{...form.getInputProps("integrations")}
|
||||
/>
|
||||
)}
|
||||
{Object.entries(definition.options).map(
|
||||
([key, value]: [string, OptionsBuilderResult[string]]) => {
|
||||
const Input = getInputForType(value.type);
|
||||
return (
|
||||
<form
|
||||
onSubmit={form.onSubmit((values) => {
|
||||
innerProps.onSuccessfulEdit(values);
|
||||
actions.closeModal();
|
||||
})}
|
||||
>
|
||||
<FormProvider form={form}>
|
||||
<Stack>
|
||||
{innerProps.integrationSupport && (
|
||||
<WidgetIntegrationSelect
|
||||
label={t("item.edit.field.integrations.label")}
|
||||
data={innerProps.integrationData}
|
||||
{...form.getInputProps("integrations")}
|
||||
/>
|
||||
)}
|
||||
{Object.entries(definition.options).map(
|
||||
([key, value]: [string, OptionsBuilderResult[string]]) => {
|
||||
const Input = getInputForType(value.type);
|
||||
|
||||
if (!Input || value.shouldHide?.(form.values.options as never)) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
!Input ||
|
||||
value.shouldHide?.(form.values.options as never)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
key={key}
|
||||
kind={innerProps.kind}
|
||||
property={key}
|
||||
options={value as never}
|
||||
/>
|
||||
);
|
||||
},
|
||||
)}
|
||||
<Group justify="right">
|
||||
<Button onClick={actions.closeModal} variant="subtle" color="gray">
|
||||
{t("common.action.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" color="teal">
|
||||
{t("common.action.saveChanges")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</FormProvider>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<Input
|
||||
key={key}
|
||||
kind={innerProps.kind}
|
||||
property={key}
|
||||
options={value as never}
|
||||
/>
|
||||
);
|
||||
},
|
||||
)}
|
||||
<Group justify="right">
|
||||
<Button
|
||||
onClick={actions.closeModal}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
>
|
||||
{t("common.action.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" color="teal">
|
||||
{t("common.action.saveChanges")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</FormProvider>
|
||||
</form>
|
||||
);
|
||||
},
|
||||
).withOptions({});
|
||||
|
||||
@@ -48,17 +48,17 @@ export const WidgetIntegrationSelect = ({
|
||||
const handleValueSelect = (selectedValue: string) =>
|
||||
onChange(
|
||||
multiSelectValues.includes(selectedValue)
|
||||
? multiSelectValues.filter((v) => v !== selectedValue)
|
||||
? multiSelectValues.filter((value) => value !== selectedValue)
|
||||
: [...multiSelectValues, selectedValue],
|
||||
);
|
||||
|
||||
const handleValueRemove = (val: string) =>
|
||||
onChange(multiSelectValues.filter((v) => v !== val));
|
||||
const handleValueRemove = (valueToRemove: string) =>
|
||||
onChange(multiSelectValues.filter((value) => value !== valueToRemove));
|
||||
|
||||
const values = multiSelectValues.map((item) => (
|
||||
<IntegrationPill
|
||||
key={item}
|
||||
option={data.find((i) => i.id === item)!}
|
||||
option={data.find((integration) => integration.id === item)!}
|
||||
onRemove={() => handleValueRemove(item)}
|
||||
/>
|
||||
));
|
||||
|
||||
Reference in New Issue
Block a user