feat: add crud for integrations (#11)
* wip: add crud for services and integrations * feat: remove services * feat: move integration definitions to homarr/definitions, add temporary test connection solution without actual request * feat: add integration count badge * feat: add translation for integrations * feat: add notifications and translate them * feat: add notice to integration forms about test connection * chore: fix ci check issues * feat: add confirm modals for integration deletion and secret card cancellation, change ordering for list page, add name property to integrations * refactor: move revalidate path action * chore: fix ci check issues * chore: install missing dependencies * chore: fix ci check issues * chore: address pull request feedback
This commit is contained in:
@@ -13,6 +13,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@homarr/auth": "workspace:^0.1.0",
|
||||
"@homarr/definitions": "workspace:^0.1.0",
|
||||
"@homarr/db": "workspace:^0.1.0",
|
||||
"@homarr/validation": "workspace:^0.1.0",
|
||||
"@trpc/client": "next",
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { integrationRouter } from "./router/integration";
|
||||
import { userRouter } from "./router/user";
|
||||
import { createTRPCRouter } from "./trpc";
|
||||
|
||||
export const appRouter = createTRPCRouter({
|
||||
user: userRouter,
|
||||
integration: integrationRouter,
|
||||
});
|
||||
|
||||
// export type definition of API
|
||||
|
||||
243
packages/api/src/router/integration.ts
Normal file
243
packages/api/src/router/integration.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
import crypto from "crypto";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import { and, createId, eq } from "@homarr/db";
|
||||
import { integrations, integrationSecrets } from "@homarr/db/schema/sqlite";
|
||||
import type { IntegrationSecretKind } from "@homarr/definitions";
|
||||
import {
|
||||
getSecretKinds,
|
||||
integrationKinds,
|
||||
integrationSecretKindObject,
|
||||
} from "@homarr/definitions";
|
||||
import { validation } from "@homarr/validation";
|
||||
|
||||
import { createTRPCRouter, publicProcedure } from "../trpc";
|
||||
|
||||
export const integrationRouter = createTRPCRouter({
|
||||
all: publicProcedure.query(async ({ ctx }) => {
|
||||
const integrations = await ctx.db.query.integrations.findMany();
|
||||
return integrations
|
||||
.map((integration) => ({
|
||||
id: integration.id,
|
||||
name: integration.name,
|
||||
kind: integration.kind,
|
||||
url: integration.url,
|
||||
}))
|
||||
.sort(
|
||||
(integrationA, integrationB) =>
|
||||
integrationKinds.indexOf(integrationA.kind) -
|
||||
integrationKinds.indexOf(integrationB.kind),
|
||||
);
|
||||
}),
|
||||
byId: publicProcedure
|
||||
.input(validation.integration.byId)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const integration = await ctx.db.query.integrations.findFirst({
|
||||
where: eq(integrations.id, input.id),
|
||||
with: {
|
||||
secrets: {
|
||||
columns: {
|
||||
kind: true,
|
||||
value: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!integration) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Integration not found",
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
id: integration.id,
|
||||
name: integration.name,
|
||||
kind: integration.kind,
|
||||
url: integration.url,
|
||||
secrets: integration.secrets.map((secret) => ({
|
||||
kind: secret.kind,
|
||||
// Only return the value if the secret is public, so for example the username
|
||||
value: integrationSecretKindObject[secret.kind].isPublic
|
||||
? decryptSecret(secret.value)
|
||||
: null,
|
||||
updatedAt: secret.updatedAt,
|
||||
})),
|
||||
};
|
||||
}),
|
||||
create: publicProcedure
|
||||
.input(validation.integration.create)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const integrationId = createId();
|
||||
await ctx.db.insert(integrations).values({
|
||||
id: integrationId,
|
||||
name: input.name,
|
||||
url: input.url,
|
||||
kind: input.kind,
|
||||
});
|
||||
|
||||
for (const secret of input.secrets) {
|
||||
await ctx.db.insert(integrationSecrets).values({
|
||||
kind: secret.kind,
|
||||
value: encryptSecret(secret.value),
|
||||
updatedAt: new Date(),
|
||||
integrationId,
|
||||
});
|
||||
}
|
||||
}),
|
||||
update: publicProcedure
|
||||
.input(validation.integration.update)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const integration = await ctx.db.query.integrations.findFirst({
|
||||
where: eq(integrations.id, input.id),
|
||||
with: {
|
||||
secrets: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!integration) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Integration not found",
|
||||
});
|
||||
}
|
||||
|
||||
await ctx.db
|
||||
.update(integrations)
|
||||
.set({
|
||||
name: input.name,
|
||||
url: input.url,
|
||||
})
|
||||
.where(eq(integrations.id, input.id));
|
||||
|
||||
const decryptedSecrets = integration.secrets.map((secret) => ({
|
||||
...secret,
|
||||
value: decryptSecret(secret.value),
|
||||
}));
|
||||
|
||||
const changedSecrets = input.secrets.filter(
|
||||
(secret): secret is { kind: IntegrationSecretKind; value: string } =>
|
||||
secret.value !== null && // only update secrets that have a value
|
||||
!decryptedSecrets.find(
|
||||
(dSecret) =>
|
||||
dSecret.kind === secret.kind && dSecret.value === secret.value,
|
||||
),
|
||||
);
|
||||
|
||||
if (changedSecrets.length > 0) {
|
||||
for (const changedSecret of changedSecrets) {
|
||||
await ctx.db
|
||||
.update(integrationSecrets)
|
||||
.set({
|
||||
value: encryptSecret(changedSecret.value),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(integrationSecrets.integrationId, input.id),
|
||||
eq(integrationSecrets.kind, changedSecret.kind),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}),
|
||||
delete: publicProcedure
|
||||
.input(validation.integration.delete)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const integration = await ctx.db.query.integrations.findFirst({
|
||||
where: eq(integrations.id, input.id),
|
||||
});
|
||||
|
||||
if (!integration) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Integration not found",
|
||||
});
|
||||
}
|
||||
|
||||
await ctx.db.delete(integrations).where(eq(integrations.id, input.id));
|
||||
}),
|
||||
testConnection: publicProcedure
|
||||
.input(validation.integration.testConnection)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const secretKinds = getSecretKinds(input.kind);
|
||||
const secrets = input.secrets.filter(
|
||||
(secret): secret is { kind: IntegrationSecretKind; value: string } =>
|
||||
!!secret.value,
|
||||
);
|
||||
const everyInputSecretDefined = secretKinds.every((secretKind) =>
|
||||
secrets.some((secret) => secret.kind === secretKind),
|
||||
);
|
||||
if (!everyInputSecretDefined && input.id === null) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "SECRETS_NOT_DEFINED",
|
||||
});
|
||||
}
|
||||
|
||||
if (!everyInputSecretDefined && input.id !== null) {
|
||||
const integration = await ctx.db.query.integrations.findFirst({
|
||||
where: eq(integrations.id, input.id),
|
||||
with: {
|
||||
secrets: true,
|
||||
},
|
||||
});
|
||||
if (!integration) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "SECRETS_NOT_DEFINED",
|
||||
});
|
||||
}
|
||||
const decryptedSecrets = integration.secrets.map((secret) => ({
|
||||
...secret,
|
||||
value: decryptSecret(secret.value),
|
||||
}));
|
||||
|
||||
// Add secrets that are not defined in the input from the database
|
||||
for (const dbSecret of decryptedSecrets) {
|
||||
if (!secrets.find((secret) => secret.kind === dbSecret.kind)) {
|
||||
secrets.push({
|
||||
kind: dbSecret.kind,
|
||||
value: dbSecret.value,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: actually test the connection
|
||||
// Probably by calling a function on the integration class
|
||||
// getIntegration(input.kind).testConnection(secrets)
|
||||
// getIntegration(kind: IntegrationKind): Integration
|
||||
// interface Integration {
|
||||
// testConnection(): Promise<void>;
|
||||
// }
|
||||
}),
|
||||
});
|
||||
|
||||
const algorithm = "aes-256-cbc"; //Using AES encryption
|
||||
const key = Buffer.from(
|
||||
"1d71cceced68159ba59a277d056a66173613052cbeeccbfbd15ab1c909455a4d",
|
||||
"hex",
|
||||
); // TODO: generate with const data = crypto.randomBytes(32).toString('hex')
|
||||
|
||||
//Encrypting text
|
||||
function encryptSecret(text: string): `${string}.${string}` {
|
||||
const iv = crypto.randomBytes(16);
|
||||
const cipher = crypto.createCipheriv(algorithm, Buffer.from(key), iv);
|
||||
let encrypted = cipher.update(text);
|
||||
encrypted = Buffer.concat([encrypted, cipher.final()]);
|
||||
return `${encrypted.toString("hex")}.${iv.toString("hex")}`;
|
||||
}
|
||||
|
||||
// Decrypting text
|
||||
function decryptSecret(value: `${string}.${string}`) {
|
||||
const [data, dataIv] = value.split(".") as [string, string];
|
||||
const iv = Buffer.from(dataIv, "hex");
|
||||
const encryptedText = Buffer.from(data, "hex");
|
||||
const decipher = crypto.createDecipheriv(algorithm, Buffer.from(key), iv);
|
||||
let decrypted = decipher.update(encryptedText);
|
||||
decrypted = Buffer.concat([decrypted, decipher.final()]);
|
||||
return decrypted.toString();
|
||||
}
|
||||
@@ -9,8 +9,8 @@
|
||||
import { initTRPC, TRPCError } from "@trpc/server";
|
||||
import superjson from "superjson";
|
||||
|
||||
import { auth } from "@homarr/auth";
|
||||
import type { Session } from "@homarr/auth";
|
||||
import { auth } from "@homarr/auth";
|
||||
import { db } from "@homarr/db";
|
||||
import { ZodError } from "@homarr/validation";
|
||||
|
||||
@@ -49,11 +49,11 @@ const createInnerTRPCContext = (opts: CreateContextOptions) => {
|
||||
* @link https://trpc.io/docs/context
|
||||
*/
|
||||
export const createTRPCContext = async (opts: {
|
||||
req?: Request;
|
||||
headers?: Headers;
|
||||
auth: Session | null;
|
||||
}) => {
|
||||
const session = opts.auth ?? (await auth());
|
||||
const source = opts.req?.headers.get("x-trpc-source") ?? "unknown";
|
||||
const source = opts.headers?.get("x-trpc-source") ?? "unknown";
|
||||
|
||||
console.log(">>> tRPC Request from", source, "by", session?.user);
|
||||
|
||||
|
||||
1
packages/common/index.ts
Normal file
1
packages/common/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./src";
|
||||
35
packages/common/package.json
Normal file
35
packages/common/package.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "@homarr/common",
|
||||
"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"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^8.53.0",
|
||||
"typescript": "^5.3.3"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"@homarr/eslint-config/base"
|
||||
]
|
||||
},
|
||||
"prettier": "@homarr/prettier-config"
|
||||
}
|
||||
2
packages/common/src/index.ts
Normal file
2
packages/common/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./object";
|
||||
export * from "./string";
|
||||
10
packages/common/src/object.ts
Normal file
10
packages/common/src/object.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export function objectKeys<O extends object>(obj: O): (keyof O)[] {
|
||||
return Object.keys(obj) as (keyof O)[];
|
||||
}
|
||||
|
||||
type Entries<T> = {
|
||||
[K in keyof T]: [K, T[K]];
|
||||
}[keyof T][];
|
||||
|
||||
export const objectEntries = <T extends object>(obj: T) =>
|
||||
Object.entries(obj) as Entries<T>;
|
||||
3
packages/common/src/string.ts
Normal file
3
packages/common/src/string.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const capitalize = <T extends string>(str: T) => {
|
||||
return (str.charAt(0).toUpperCase() + str.slice(1)) as Capitalize<T>;
|
||||
};
|
||||
8
packages/common/tsconfig.json
Normal file
8
packages/common/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@homarr/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
|
||||
},
|
||||
"include": ["*.ts", "src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -14,6 +14,8 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@homarr/common": "workspace:^0.1.0",
|
||||
"@homarr/definitions": "workspace:^0.1.0",
|
||||
"@paralleldrive/cuid2": "^2.2.2",
|
||||
"better-sqlite3": "^9.2.2",
|
||||
"drizzle-orm": "^0.29.1"
|
||||
|
||||
@@ -9,6 +9,11 @@ import {
|
||||
text,
|
||||
} from "drizzle-orm/sqlite-core";
|
||||
|
||||
import type {
|
||||
IntegrationKind,
|
||||
IntegrationSecretKind,
|
||||
} from "@homarr/definitions";
|
||||
|
||||
export const users = sqliteTable("user", {
|
||||
id: text("id").notNull().primaryKey(),
|
||||
name: text("name"),
|
||||
@@ -70,6 +75,38 @@ export const verificationTokens = sqliteTable(
|
||||
}),
|
||||
);
|
||||
|
||||
export const integrations = sqliteTable(
|
||||
"integration",
|
||||
{
|
||||
id: text("id").notNull().primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
url: text("url").notNull(),
|
||||
kind: text("kind").$type<IntegrationKind>().notNull(),
|
||||
},
|
||||
(i) => ({
|
||||
kindIdx: index("integration__kind_idx").on(i.kind),
|
||||
}),
|
||||
);
|
||||
|
||||
export const integrationSecrets = sqliteTable(
|
||||
"integrationSecret",
|
||||
{
|
||||
kind: text("kind").$type<IntegrationSecretKind>().notNull(),
|
||||
value: text("value").$type<`${string}.${string}`>().notNull(),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
|
||||
integrationId: text("integration_id")
|
||||
.notNull()
|
||||
.references(() => integrations.id, { onDelete: "cascade" }),
|
||||
},
|
||||
(is) => ({
|
||||
compoundKey: primaryKey({
|
||||
columns: [is.integrationId, is.kind],
|
||||
}),
|
||||
kindIdx: index("integration_secret__kind_idx").on(is.kind),
|
||||
updatedAtIdx: index("integration_secret__updated_at_idx").on(is.updatedAt),
|
||||
}),
|
||||
);
|
||||
|
||||
export const accountRelations = relations(accounts, ({ one }) => ({
|
||||
user: one(users, {
|
||||
fields: [accounts.userId],
|
||||
@@ -81,7 +118,23 @@ export const userRelations = relations(users, ({ many }) => ({
|
||||
accounts: many(accounts),
|
||||
}));
|
||||
|
||||
export const integrationRelations = relations(integrations, ({ many }) => ({
|
||||
secrets: many(integrationSecrets),
|
||||
}));
|
||||
|
||||
export const integrationSecretRelations = relations(
|
||||
integrationSecrets,
|
||||
({ one }) => ({
|
||||
integration: one(integrations, {
|
||||
fields: [integrationSecrets.integrationId],
|
||||
references: [integrations.id],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
export type User = InferSelectModel<typeof users>;
|
||||
export type Account = InferSelectModel<typeof accounts>;
|
||||
export type Session = InferSelectModel<typeof sessions>;
|
||||
export type VerificationToken = InferSelectModel<typeof verificationTokens>;
|
||||
export type Integration = InferSelectModel<typeof integrations>;
|
||||
export type IntegrationSecret = InferSelectModel<typeof integrationSecrets>;
|
||||
|
||||
1
packages/definitions/index.ts
Normal file
1
packages/definitions/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./src";
|
||||
38
packages/definitions/package.json
Normal file
38
packages/definitions/package.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "@homarr/definitions",
|
||||
"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"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^8.53.0",
|
||||
"typescript": "^5.3.3"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"@homarr/eslint-config/base"
|
||||
]
|
||||
},
|
||||
"prettier": "@homarr/prettier-config",
|
||||
"dependencies": {
|
||||
"@homarr/common": "workspace:^0.1.0"
|
||||
}
|
||||
}
|
||||
1
packages/definitions/src/index.ts
Normal file
1
packages/definitions/src/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./integration";
|
||||
155
packages/definitions/src/integration.ts
Normal file
155
packages/definitions/src/integration.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { objectKeys } from "@homarr/common";
|
||||
|
||||
export const integrationSecretKindObject = {
|
||||
apiKey: { isPublic: false },
|
||||
username: { isPublic: true },
|
||||
password: { isPublic: false },
|
||||
} satisfies Record<string, { isPublic: boolean }>;
|
||||
|
||||
export const integrationSecretKinds = objectKeys(integrationSecretKindObject);
|
||||
|
||||
export const integrationDefs = {
|
||||
sabNzbd: {
|
||||
name: "SABnzbd",
|
||||
secretKinds: ["apiKey"],
|
||||
iconUrl:
|
||||
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/sabnzbd.png",
|
||||
category: ["useNetClient"],
|
||||
},
|
||||
nzbGet: {
|
||||
name: "NZBGet",
|
||||
secretKinds: ["username", "password"],
|
||||
iconUrl:
|
||||
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/nzbget.png",
|
||||
category: ["useNetClient"],
|
||||
},
|
||||
deluge: {
|
||||
name: "Deluge",
|
||||
secretKinds: ["password"],
|
||||
iconUrl:
|
||||
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/deluge.png",
|
||||
category: ["downloadClient"],
|
||||
},
|
||||
transmission: {
|
||||
name: "Transmission",
|
||||
secretKinds: ["username", "password"],
|
||||
iconUrl:
|
||||
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/transmission.png",
|
||||
category: ["downloadClient"],
|
||||
},
|
||||
qBittorrent: {
|
||||
name: "qBittorrent",
|
||||
secretKinds: ["username", "password"],
|
||||
iconUrl:
|
||||
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/qbittorrent.png",
|
||||
category: ["downloadClient"],
|
||||
},
|
||||
sonarr: {
|
||||
name: "Sonarr",
|
||||
secretKinds: ["apiKey"],
|
||||
iconUrl:
|
||||
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/sonarr.png",
|
||||
category: ["calendar"],
|
||||
},
|
||||
radarr: {
|
||||
name: "Radarr",
|
||||
secretKinds: ["apiKey"],
|
||||
iconUrl:
|
||||
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/radarr.png",
|
||||
category: ["calendar"],
|
||||
},
|
||||
lidarr: {
|
||||
name: "Lidarr",
|
||||
secretKinds: ["apiKey"],
|
||||
iconUrl:
|
||||
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/lidarr.png",
|
||||
category: ["calendar"],
|
||||
},
|
||||
readarr: {
|
||||
name: "Readarr",
|
||||
secretKinds: ["apiKey"],
|
||||
iconUrl:
|
||||
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/readarr.png",
|
||||
category: ["calendar"],
|
||||
},
|
||||
jellyfin: {
|
||||
name: "Jellyfin",
|
||||
secretKinds: ["username", "password"],
|
||||
iconUrl:
|
||||
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/jellyfin.png",
|
||||
category: ["mediaService"],
|
||||
},
|
||||
plex: {
|
||||
name: "Plex",
|
||||
secretKinds: ["apiKey"],
|
||||
iconUrl:
|
||||
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/plex.png",
|
||||
category: ["mediaService"],
|
||||
},
|
||||
jellyseerr: {
|
||||
name: "Jellyseerr",
|
||||
secretKinds: ["apiKey"],
|
||||
iconUrl:
|
||||
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/jellyseerr.png",
|
||||
category: ["mediaSearch", "mediaRequest"],
|
||||
},
|
||||
overseerr: {
|
||||
name: "Overseerr",
|
||||
secretKinds: ["apiKey"],
|
||||
iconUrl:
|
||||
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/overseerr.png",
|
||||
category: ["mediaSearch", "mediaRequest"],
|
||||
},
|
||||
piHole: {
|
||||
name: "Pi-hole",
|
||||
secretKinds: ["apiKey"],
|
||||
iconUrl:
|
||||
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/pi-hole.png",
|
||||
category: ["dnsHole"],
|
||||
},
|
||||
adGuardHome: {
|
||||
name: "AdGuard Home",
|
||||
secretKinds: ["username", "password"],
|
||||
iconUrl:
|
||||
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/adguard-home.png",
|
||||
category: ["dnsHole"],
|
||||
},
|
||||
homeAssistant: {
|
||||
name: "Home Assistant",
|
||||
secretKinds: ["apiKey"],
|
||||
iconUrl:
|
||||
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/home-assistant.png",
|
||||
category: [],
|
||||
},
|
||||
} satisfies Record<
|
||||
string,
|
||||
{
|
||||
name: string;
|
||||
iconUrl: string;
|
||||
secretKinds: IntegrationSecretKind[];
|
||||
category: IntegrationCategory[];
|
||||
}
|
||||
>;
|
||||
|
||||
export const getIconUrl = (integration: IntegrationKind) =>
|
||||
integrationDefs[integration]?.iconUrl ?? null;
|
||||
|
||||
export const getIntegrationName = (integration: IntegrationKind) =>
|
||||
integrationDefs[integration].name;
|
||||
|
||||
export const getSecretKinds = (
|
||||
integration: IntegrationKind,
|
||||
): IntegrationSecretKind[] => integrationDefs[integration]?.secretKinds ?? null;
|
||||
|
||||
export const integrationKinds = objectKeys(integrationDefs);
|
||||
|
||||
export type IntegrationSecretKind = (typeof integrationSecretKinds)[number];
|
||||
export type IntegrationKind = (typeof integrationKinds)[number];
|
||||
export type IntegrationCategory =
|
||||
| "dnsHole"
|
||||
| "mediaService"
|
||||
| "calendar"
|
||||
| "mediaSearch"
|
||||
| "mediaRequest"
|
||||
| "downloadClient"
|
||||
| "useNetClient";
|
||||
8
packages/definitions/tsconfig.json
Normal file
8
packages/definitions/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@homarr/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
|
||||
},
|
||||
"include": ["*.ts", "src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { NotificationData } from "@mantine/notifications";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
|
||||
import { IconCheck, IconX, rem } from "@homarr/ui";
|
||||
import { IconCheck, IconX } from "@homarr/ui";
|
||||
|
||||
type CommonNotificationProps = Pick<NotificationData, "title" | "message">;
|
||||
|
||||
@@ -9,12 +9,12 @@ export const showSuccessNotification = (props: CommonNotificationProps) =>
|
||||
notifications.show({
|
||||
...props,
|
||||
color: "teal",
|
||||
icon: <IconCheck size={rem(20)} />,
|
||||
icon: <IconCheck size={20} />,
|
||||
});
|
||||
|
||||
export const showErrorNotification = (props: CommonNotificationProps) =>
|
||||
notifications.show({
|
||||
...props,
|
||||
color: "red",
|
||||
icon: <IconX size={rem(20)} />,
|
||||
icon: <IconX size={20} />,
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import "dayjs/locale/de";
|
||||
|
||||
export default {
|
||||
user: {
|
||||
page: {
|
||||
@@ -26,6 +28,132 @@ export default {
|
||||
create: "Benutzer erstellen",
|
||||
},
|
||||
},
|
||||
integration: {
|
||||
page: {
|
||||
list: {
|
||||
title: "Integrationen",
|
||||
search: "Integration suchen",
|
||||
empty: "Keine Integrationen gefunden",
|
||||
},
|
||||
create: {
|
||||
title: "Neue {name} Integration erstellen",
|
||||
notification: {
|
||||
success: {
|
||||
title: "Erstellung erfolgreich",
|
||||
message: "Die Integration wurde erfolgreich erstellt",
|
||||
},
|
||||
error: {
|
||||
title: "Erstellung fehlgeschlagen",
|
||||
message: "Die Integration konnte nicht erstellt werden",
|
||||
},
|
||||
},
|
||||
},
|
||||
edit: {
|
||||
title: "{name} Integration bearbeiten",
|
||||
notification: {
|
||||
success: {
|
||||
title: "Änderungen erfolgreich angewendet",
|
||||
message: "Die Integration wurde erfolgreich gespeichert",
|
||||
},
|
||||
error: {
|
||||
title: "Änderungen konnten nicht angewendet werden",
|
||||
message: "Die Integration konnte nicht gespeichert werden",
|
||||
},
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
title: "Integration entfernen",
|
||||
message: "Möchtest du die Integration {name} wirklich entfernen?",
|
||||
notification: {
|
||||
success: {
|
||||
title: "Entfernen erfolgreich",
|
||||
message: "Die Integration wurde erfolgreich entfernt",
|
||||
},
|
||||
error: {
|
||||
title: "Entfernen fehlgeschlagen",
|
||||
message: "Die Integration konnte nicht entfernt werden",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
field: {
|
||||
name: {
|
||||
label: "Name",
|
||||
},
|
||||
url: {
|
||||
label: "Url",
|
||||
},
|
||||
},
|
||||
action: {
|
||||
create: "Neue Integration",
|
||||
},
|
||||
testConnection: {
|
||||
action: "Verbindung überprüfen",
|
||||
alertNotice:
|
||||
"Der Button zum Speichern wird aktiviert, sobald die Verbindung erfolgreich überprüft wurde",
|
||||
notification: {
|
||||
success: {
|
||||
title: "Verbindung erfolgreich",
|
||||
message: "Die Verbindung wurde erfolgreich hergestellt",
|
||||
},
|
||||
invalidUrl: {
|
||||
title: "Ungültige URL",
|
||||
message: "Die URL ist ungültig",
|
||||
},
|
||||
notAllSecretsProvided: {
|
||||
title: "Fehlende Zugangsdaten",
|
||||
message: "Es wurden nicht alle Zugangsdaten angegeben",
|
||||
},
|
||||
invalidCredentials: {
|
||||
title: "Ungültige Zugangsdaten",
|
||||
message: "Die Zugangsdaten sind ungültig",
|
||||
},
|
||||
commonError: {
|
||||
title: "Verbindung fehlgeschlagen",
|
||||
message: "Die Verbindung konnte nicht hergestellt werden",
|
||||
},
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
title: "Zugangsdaten",
|
||||
lastUpdated: "Zuletzt geändert {date}",
|
||||
secureNotice:
|
||||
"Diese Zugangsdaten können nach der Erstellung nicht mehr ausgelesen werden",
|
||||
reset: {
|
||||
title: "Zugangsdaten zurücksetzen",
|
||||
message: "Möchtest du diese Zugangsdaten wirklich zurücksetzen?",
|
||||
},
|
||||
kind: {
|
||||
username: {
|
||||
label: "Benutzername",
|
||||
newLabel: "Neuer Benutzername",
|
||||
},
|
||||
apiKey: {
|
||||
label: "API Key",
|
||||
newLabel: "Neuer API Key",
|
||||
},
|
||||
password: {
|
||||
label: "Passwort",
|
||||
newLabel: "Neues Passwort",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
common: {
|
||||
action: {
|
||||
backToOverview: "Zurück zur Übersicht",
|
||||
create: "Erstellen",
|
||||
edit: "Bearbeiten",
|
||||
save: "Speichern",
|
||||
cancel: "Abbrechen",
|
||||
confirm: "Bestätigen",
|
||||
},
|
||||
noResults: "Keine Ergebnisse gefunden",
|
||||
search: {
|
||||
placeholder: "Suche nach etwas...",
|
||||
nothingFound: "Nichts gefunden",
|
||||
},
|
||||
},
|
||||
widget: {
|
||||
clock: {
|
||||
option: {
|
||||
@@ -52,10 +180,4 @@ export default {
|
||||
},
|
||||
},
|
||||
},
|
||||
common: {
|
||||
search: {
|
||||
placeholder: "Suche nach etwas...",
|
||||
nothingFound: "Nichts gefunden",
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import "dayjs/locale/en";
|
||||
|
||||
export default {
|
||||
user: {
|
||||
page: {
|
||||
@@ -26,6 +28,131 @@ export default {
|
||||
create: "Create user",
|
||||
},
|
||||
},
|
||||
integration: {
|
||||
page: {
|
||||
list: {
|
||||
title: "Integrations",
|
||||
search: "Search integrations",
|
||||
empty: "No integrations found",
|
||||
},
|
||||
create: {
|
||||
title: "New {name} integration",
|
||||
notification: {
|
||||
success: {
|
||||
title: "Creation successful",
|
||||
message: "The integration was successfully created",
|
||||
},
|
||||
error: {
|
||||
title: "Creation failed",
|
||||
message: "The integration could not be created",
|
||||
},
|
||||
},
|
||||
},
|
||||
edit: {
|
||||
title: "Edit {name} integration",
|
||||
notification: {
|
||||
success: {
|
||||
title: "Changes applied successfully",
|
||||
message: "The integration was successfully saved",
|
||||
},
|
||||
error: {
|
||||
title: "Unable to apply changes",
|
||||
message: "The integration could not be saved",
|
||||
},
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
title: "Delete integration",
|
||||
message: "Are you sure you want to delete the integration {name}?",
|
||||
notification: {
|
||||
success: {
|
||||
title: "Deletion successful",
|
||||
message: "The integration was successfully deleted",
|
||||
},
|
||||
error: {
|
||||
title: "Deletion failed",
|
||||
message: "Unable to delete the integration",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
field: {
|
||||
name: {
|
||||
label: "Name",
|
||||
},
|
||||
url: {
|
||||
label: "Url",
|
||||
},
|
||||
},
|
||||
action: {
|
||||
create: "New integration",
|
||||
},
|
||||
testConnection: {
|
||||
action: "Test connection",
|
||||
alertNotice:
|
||||
"The Save button is enabled once a successful connection is established",
|
||||
notification: {
|
||||
success: {
|
||||
title: "Connection successful",
|
||||
message: "The connection was successfully established",
|
||||
},
|
||||
invalidUrl: {
|
||||
title: "Invalid URL",
|
||||
message: "The URL is invalid",
|
||||
},
|
||||
notAllSecretsProvided: {
|
||||
title: "Missing credentials",
|
||||
message: "Not all credentials were provided",
|
||||
},
|
||||
invalidCredentials: {
|
||||
title: "Invalid credentials",
|
||||
message: "The credentials are invalid",
|
||||
},
|
||||
commonError: {
|
||||
title: "Connection failed",
|
||||
message: "The connection could not be established",
|
||||
},
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
title: "Secrets",
|
||||
lastUpdated: "Last updated {date}",
|
||||
secureNotice: "This secret cannot be retrieved after creation",
|
||||
reset: {
|
||||
title: "Reset secret",
|
||||
message: "Are you sure you want to reset this secret?",
|
||||
},
|
||||
kind: {
|
||||
username: {
|
||||
label: "Username",
|
||||
newLabel: "New username",
|
||||
},
|
||||
apiKey: {
|
||||
label: "API Key",
|
||||
newLabel: "New API Key",
|
||||
},
|
||||
password: {
|
||||
label: "Password",
|
||||
newLabel: "New password",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
common: {
|
||||
action: {
|
||||
backToOverview: "Back to overview",
|
||||
create: "Create",
|
||||
edit: "Edit",
|
||||
save: "Save",
|
||||
cancel: "Cancel",
|
||||
confirm: "Confirm",
|
||||
},
|
||||
search: {
|
||||
placeholder: "Search for anything...",
|
||||
nothingFound: "Nothing found",
|
||||
},
|
||||
noResults: "No results found",
|
||||
},
|
||||
widget: {
|
||||
clock: {
|
||||
option: {
|
||||
@@ -52,12 +179,6 @@ export default {
|
||||
},
|
||||
},
|
||||
},
|
||||
common: {
|
||||
search: {
|
||||
placeholder: "Search for anything...",
|
||||
nothingFound: "Nothing found",
|
||||
},
|
||||
},
|
||||
management: {
|
||||
metaTitle: "Management",
|
||||
title: {
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"@types/css-modules": "^1.0.5",
|
||||
"eslint": "^8.53.0",
|
||||
"typescript": "^5.3.3"
|
||||
},
|
||||
|
||||
11
packages/ui/src/components/count-badge.module.css
Normal file
11
packages/ui/src/components/count-badge.module.css
Normal file
@@ -0,0 +1,11 @@
|
||||
.badge {
|
||||
@mixin light {
|
||||
--badge-bg: rgba(30, 34, 39, 0.08);
|
||||
--badge-color: var(--mantine-color-black);
|
||||
}
|
||||
|
||||
@mixin dark {
|
||||
--badge-bg: #363c44;
|
||||
--badge-color: var(--mantine-color-white);
|
||||
}
|
||||
}
|
||||
11
packages/ui/src/components/count-badge.tsx
Normal file
11
packages/ui/src/components/count-badge.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Badge } from "@mantine/core";
|
||||
|
||||
import classes from "./count-badge.module.css";
|
||||
|
||||
interface CountBadgeProps {
|
||||
count: number;
|
||||
}
|
||||
|
||||
export const CountBadge = ({ count }: CountBadgeProps) => {
|
||||
return <Badge className={classes.badge}>{count}</Badge>;
|
||||
};
|
||||
1
packages/ui/src/components/index.tsx
Normal file
1
packages/ui/src/components/index.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./count-badge";
|
||||
@@ -2,6 +2,8 @@ import type { MantineProviderProps } from "@mantine/core";
|
||||
|
||||
import { theme } from "./theme";
|
||||
|
||||
export * from "./components";
|
||||
|
||||
export const uiConfiguration = {
|
||||
theme,
|
||||
} satisfies MantineProviderProps;
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
},
|
||||
"prettier": "@homarr/prettier-config",
|
||||
"dependencies": {
|
||||
"zod": "^3.22.2"
|
||||
"zod": "^3.22.2",
|
||||
"@homarr/definitions": "workspace:^0.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
4
packages/validation/src/enums.ts
Normal file
4
packages/validation/src/enums.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const zodEnumFromArray = <T extends string>(arr: T[]) =>
|
||||
z.enum([arr[0]!, ...arr.slice(1)]);
|
||||
@@ -1,5 +1,7 @@
|
||||
import { integrationSchemas } from "./integration";
|
||||
import { userSchemas } from "./user";
|
||||
|
||||
export const validation = {
|
||||
user: userSchemas,
|
||||
integration: integrationSchemas,
|
||||
};
|
||||
|
||||
53
packages/validation/src/integration.ts
Normal file
53
packages/validation/src/integration.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { integrationKinds, integrationSecretKinds } from "@homarr/definitions";
|
||||
|
||||
import { zodEnumFromArray } from "./enums";
|
||||
|
||||
const integrationCreateSchema = z.object({
|
||||
name: z.string().nonempty().max(127),
|
||||
url: z.string().url(),
|
||||
kind: zodEnumFromArray(integrationKinds),
|
||||
secrets: z.array(
|
||||
z.object({
|
||||
kind: zodEnumFromArray(integrationSecretKinds),
|
||||
value: z.string().nonempty(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
const integrationUpdateSchema = z.object({
|
||||
id: z.string().cuid2(),
|
||||
name: z.string().nonempty().max(127),
|
||||
url: z.string().url(),
|
||||
secrets: z.array(
|
||||
z.object({
|
||||
kind: zodEnumFromArray(integrationSecretKinds),
|
||||
value: z.string().nullable(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
const idSchema = z.object({
|
||||
id: z.string(),
|
||||
});
|
||||
|
||||
const testConnectionSchema = z.object({
|
||||
id: z.string().cuid2().nullable(), // Is used to use existing secrets if they have not been updated
|
||||
url: z.string().url(),
|
||||
kind: zodEnumFromArray(integrationKinds),
|
||||
secrets: z.array(
|
||||
z.object({
|
||||
kind: zodEnumFromArray(integrationSecretKinds),
|
||||
value: z.string().nullable(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
export const integrationSchemas = {
|
||||
create: integrationCreateSchema,
|
||||
update: integrationUpdateSchema,
|
||||
delete: idSchema,
|
||||
byId: idSchema,
|
||||
testConnection: testConnectionSchema,
|
||||
};
|
||||
Reference in New Issue
Block a user