feat: add everyone group (#1322)
* feat: add everyone group through seed * feat: add reserved group name check in group router actions * feat: improve user interface for everyone group * fix: reserved group alert is a server component * feat: add all users to everyone group * chore: update lockfile * fix: format issues * fix: lint issues * fix: lint format issues * test: add unit tests for everyone group * refactor: add codegen for documentation urls by sitemap * refactor: change group query to count * chore: remove migrations temporarily * chore: add migrations again * chore: add lint rule to prevent usage of raw documentation links * fix: format issues
This commit is contained in:
@@ -3,6 +3,7 @@ import { TRPCError } from "@trpc/server";
|
||||
import type { Database } from "@homarr/db";
|
||||
import { and, createId, eq, like, not, sql } from "@homarr/db";
|
||||
import { groupMembers, groupPermissions, groups } from "@homarr/db/schema/sqlite";
|
||||
import { everyoneGroup } from "@homarr/definitions";
|
||||
import { validation, z } from "@homarr/validation";
|
||||
|
||||
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure } from "../trpc";
|
||||
@@ -121,13 +122,12 @@ export const groupRouter = createTRPCRouter({
|
||||
.requiresPermission("admin")
|
||||
.input(validation.group.create)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const normalizedName = normalizeName(input.name);
|
||||
await checkSimilarNameAndThrowAsync(ctx.db, normalizedName);
|
||||
await checkSimilarNameAndThrowAsync(ctx.db, input.name);
|
||||
|
||||
const id = createId();
|
||||
await ctx.db.insert(groups).values({
|
||||
id,
|
||||
name: normalizedName,
|
||||
name: input.name,
|
||||
ownerId: ctx.session.user.id,
|
||||
});
|
||||
|
||||
@@ -138,14 +138,14 @@ export const groupRouter = createTRPCRouter({
|
||||
.input(validation.group.update)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
await throwIfGroupNotFoundAsync(ctx.db, input.id);
|
||||
await throwIfGroupNameIsReservedAsync(ctx.db, input.id);
|
||||
|
||||
const normalizedName = normalizeName(input.name);
|
||||
await checkSimilarNameAndThrowAsync(ctx.db, normalizedName, input.id);
|
||||
await checkSimilarNameAndThrowAsync(ctx.db, input.name, input.id);
|
||||
|
||||
await ctx.db
|
||||
.update(groups)
|
||||
.set({
|
||||
name: normalizedName,
|
||||
name: input.name,
|
||||
})
|
||||
.where(eq(groups.id, input.id));
|
||||
}),
|
||||
@@ -169,6 +169,7 @@ export const groupRouter = createTRPCRouter({
|
||||
.input(validation.group.groupUser)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
await throwIfGroupNotFoundAsync(ctx.db, input.groupId);
|
||||
await throwIfGroupNameIsReservedAsync(ctx.db, input.groupId);
|
||||
|
||||
await ctx.db
|
||||
.update(groups)
|
||||
@@ -182,6 +183,7 @@ export const groupRouter = createTRPCRouter({
|
||||
.input(validation.common.byId)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
await throwIfGroupNotFoundAsync(ctx.db, input.id);
|
||||
await throwIfGroupNameIsReservedAsync(ctx.db, input.id);
|
||||
|
||||
await ctx.db.delete(groups).where(eq(groups.id, input.id));
|
||||
}),
|
||||
@@ -190,6 +192,7 @@ export const groupRouter = createTRPCRouter({
|
||||
.input(validation.group.groupUser)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
await throwIfGroupNotFoundAsync(ctx.db, input.groupId);
|
||||
await throwIfGroupNameIsReservedAsync(ctx.db, input.groupId);
|
||||
throwIfCredentialsDisabled();
|
||||
|
||||
const user = await ctx.db.query.users.findFirst({
|
||||
@@ -213,6 +216,7 @@ export const groupRouter = createTRPCRouter({
|
||||
.input(validation.group.groupUser)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
await throwIfGroupNotFoundAsync(ctx.db, input.groupId);
|
||||
await throwIfGroupNameIsReservedAsync(ctx.db, input.groupId);
|
||||
throwIfCredentialsDisabled();
|
||||
|
||||
await ctx.db
|
||||
@@ -221,8 +225,6 @@ export const groupRouter = createTRPCRouter({
|
||||
}),
|
||||
});
|
||||
|
||||
const normalizeName = (name: string) => name.trim();
|
||||
|
||||
const checkSimilarNameAndThrowAsync = async (db: Database, name: string, ignoreId?: string) => {
|
||||
const similar = await db.query.groups.findFirst({
|
||||
where: and(like(groups.name, `${name}`), not(eq(groups.id, ignoreId ?? ""))),
|
||||
@@ -236,6 +238,17 @@ const checkSimilarNameAndThrowAsync = async (db: Database, name: string, ignoreI
|
||||
}
|
||||
};
|
||||
|
||||
const throwIfGroupNameIsReservedAsync = async (db: Database, id: string) => {
|
||||
const count = await db.$count(groups, and(eq(groups.id, id), eq(groups.name, everyoneGroup)));
|
||||
|
||||
if (count > 0) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Action is forbidden for reserved group names",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const throwIfGroupNotFoundAsync = async (db: Database, id: string) => {
|
||||
const group = await db.query.groups.findFirst({
|
||||
where: eq(groups.id, id),
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { NextAuthConfig } from "next-auth";
|
||||
import { and, eq, inArray } from "@homarr/db";
|
||||
import type { Database } from "@homarr/db";
|
||||
import { groupMembers, groups, users } from "@homarr/db/schema/sqlite";
|
||||
import { everyoneGroup } from "@homarr/definitions";
|
||||
import { logger } from "@homarr/log";
|
||||
|
||||
import { env } from "./env.mjs";
|
||||
@@ -33,6 +34,7 @@ export const createSignInEventHandler = (db: Database): Exclude<NextAuthConfig["
|
||||
if ("groups" in user && Array.isArray(user.groups)) {
|
||||
await synchronizeGroupsWithExternalForUserAsync(db, user.id, user.groups as string[]);
|
||||
}
|
||||
await addUserToEveryoneGroupIfNotMemberAsync(db, user.id);
|
||||
|
||||
if (dbUser.name !== user.name) {
|
||||
await db.update(users).set({ name: user.name }).where(eq(users.id, user.id));
|
||||
@@ -57,7 +59,27 @@ export const createSignInEventHandler = (db: Database): Exclude<NextAuthConfig["
|
||||
};
|
||||
};
|
||||
|
||||
const addUserToEveryoneGroupIfNotMemberAsync = async (db: Database, userId: string) => {
|
||||
const dbEveryoneGroup = await db.query.groups.findFirst({
|
||||
where: eq(groups.name, everyoneGroup),
|
||||
with: {
|
||||
members: {
|
||||
where: eq(groupMembers.userId, userId),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (dbEveryoneGroup?.members.length === 0) {
|
||||
await db.insert(groupMembers).values({
|
||||
userId,
|
||||
groupId: dbEveryoneGroup.id,
|
||||
});
|
||||
logger.info(`Added user to everyone group. user=${userId}`);
|
||||
}
|
||||
};
|
||||
|
||||
const synchronizeGroupsWithExternalForUserAsync = async (db: Database, userId: string, externalGroups: string[]) => {
|
||||
const ignoredGroups = [everyoneGroup];
|
||||
const dbGroupMembers = await db.query.groupMembers.findMany({
|
||||
where: eq(groupMembers.userId, userId),
|
||||
with: {
|
||||
@@ -102,11 +124,11 @@ const synchronizeGroupsWithExternalForUserAsync = async (db: Database, userId: s
|
||||
}
|
||||
|
||||
/**
|
||||
* The below groups are those groups the user is part of in Homarr, but not in the external system.
|
||||
* The below groups are those groups the user is part of in Homarr, but not in the external system and not ignored.
|
||||
* So he has to be removed from those groups.
|
||||
*/
|
||||
const groupsUserIsNoLongerMemberOfExternally = dbGroupMembers.filter(
|
||||
({ group }) => !externalGroups.includes(group.name),
|
||||
({ group }) => !externalGroups.concat(ignoredGroups).includes(group.name),
|
||||
);
|
||||
|
||||
if (groupsUserIsNoLongerMemberOfExternally.length > 0) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { eq } from "@homarr/db";
|
||||
import type { Database } from "@homarr/db";
|
||||
import { groupMembers, groups, users } from "@homarr/db/schema/sqlite";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
import { everyoneGroup } from "@homarr/definitions";
|
||||
|
||||
import { createSignInEventHandler } from "../events";
|
||||
|
||||
@@ -34,6 +35,29 @@ vi.mock("next/headers", async (importOriginal) => {
|
||||
});
|
||||
|
||||
describe("createSignInEventHandler should create signInEventHandler", () => {
|
||||
describe("signInEventHandler should add users to everyone group", () => {
|
||||
test("should add user to everyone group if he isn't already", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
await createUserAsync(db);
|
||||
await createGroupAsync(db, everyoneGroup);
|
||||
const eventHandler = createSignInEventHandler(db);
|
||||
|
||||
// Act
|
||||
await eventHandler?.({
|
||||
user: { id: "1", name: "test" },
|
||||
profile: undefined,
|
||||
account: null,
|
||||
});
|
||||
|
||||
// Assert
|
||||
const dbGroupMembers = await db.query.groupMembers.findFirst({
|
||||
where: eq(groupMembers.userId, "1"),
|
||||
});
|
||||
expect(dbGroupMembers?.groupId).toBe("1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("signInEventHandler should synchronize ldap groups", () => {
|
||||
test("should add missing group membership", async () => {
|
||||
// Arrange
|
||||
@@ -79,6 +103,30 @@ describe("createSignInEventHandler should create signInEventHandler", () => {
|
||||
});
|
||||
expect(dbGroupMembers).toBeUndefined();
|
||||
});
|
||||
test("should not remove group membership for everyone group", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
await createUserAsync(db);
|
||||
await createGroupAsync(db, everyoneGroup);
|
||||
await db.insert(groupMembers).values({
|
||||
userId: "1",
|
||||
groupId: "1",
|
||||
});
|
||||
const eventHandler = createSignInEventHandler(db);
|
||||
|
||||
// Act
|
||||
await eventHandler?.({
|
||||
user: { id: "1", name: "test", groups: [] } as never,
|
||||
profile: undefined,
|
||||
account: null,
|
||||
});
|
||||
|
||||
// Assert
|
||||
const dbGroupMembers = await db.query.groupMembers.findFirst({
|
||||
where: eq(groupMembers.userId, "1"),
|
||||
});
|
||||
expect(dbGroupMembers?.groupId).toBe("1");
|
||||
});
|
||||
});
|
||||
describe("signInEventHandler should synchronize oidc groups", () => {
|
||||
test("should add missing group membership", async () => {
|
||||
@@ -125,6 +173,30 @@ describe("createSignInEventHandler should create signInEventHandler", () => {
|
||||
});
|
||||
expect(dbGroupMembers).toBeUndefined();
|
||||
});
|
||||
test("should not remove group membership for everyone group", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
await createUserAsync(db);
|
||||
await createGroupAsync(db, everyoneGroup);
|
||||
await db.insert(groupMembers).values({
|
||||
userId: "1",
|
||||
groupId: "1",
|
||||
});
|
||||
const eventHandler = createSignInEventHandler(db);
|
||||
|
||||
// Act
|
||||
await eventHandler?.({
|
||||
user: { id: "1", name: "test" },
|
||||
profile: { preferred_username: "test", someRandomGroupsKey: [] },
|
||||
account: null,
|
||||
});
|
||||
|
||||
// Assert
|
||||
const dbGroupMembers = await db.query.groupMembers.findFirst({
|
||||
where: eq(groupMembers.userId, "1"),
|
||||
});
|
||||
expect(dbGroupMembers?.groupId).toBe("1");
|
||||
});
|
||||
});
|
||||
test.each([
|
||||
["ldap" as const, { name: "test-new" }, undefined],
|
||||
@@ -183,8 +255,8 @@ const createUserAsync = async (db: Database) =>
|
||||
colorScheme: "dark",
|
||||
});
|
||||
|
||||
const createGroupAsync = async (db: Database) =>
|
||||
const createGroupAsync = async (db: Database, name = "test") =>
|
||||
await db.insert(groups).values({
|
||||
id: "1",
|
||||
name: "test",
|
||||
name,
|
||||
});
|
||||
|
||||
1
packages/db/migrations/mysql/0013_youthful_vulture.sql
Normal file
1
packages/db/migrations/mysql/0013_youthful_vulture.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE `group` ADD CONSTRAINT `group_name_unique` UNIQUE(`name`);
|
||||
1527
packages/db/migrations/mysql/meta/0013_snapshot.json
Normal file
1527
packages/db/migrations/mysql/meta/0013_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -92,6 +92,13 @@
|
||||
"when": 1729348221072,
|
||||
"tag": "0012_abnormal_wendell_vaughn",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 13,
|
||||
"version": "5",
|
||||
"when": 1729369383739,
|
||||
"tag": "0013_youthful_vulture",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -3,25 +3,35 @@ import { drizzle } from "drizzle-orm/mysql2";
|
||||
import { migrate } from "drizzle-orm/mysql2/migrator";
|
||||
import mysql from "mysql2";
|
||||
|
||||
import type { Database } from "../..";
|
||||
import * as mysqlSchema from "../../schema/mysql";
|
||||
import { seedDataAsync } from "../seed";
|
||||
|
||||
const migrationsFolder = process.argv[2] ?? ".";
|
||||
|
||||
const mysql2 = mysql.createConnection(
|
||||
process.env.DB_HOST
|
||||
? {
|
||||
host: process.env.DB_HOST,
|
||||
database: process.env.DB_NAME!,
|
||||
port: Number(process.env.DB_PORT),
|
||||
user: process.env.DB_USER,
|
||||
password: process.env.DB_PASSWORD,
|
||||
}
|
||||
: { uri: process.env.DB_URL },
|
||||
);
|
||||
const migrateAsync = async () => {
|
||||
const mysql2 = mysql.createConnection(
|
||||
process.env.DB_HOST
|
||||
? {
|
||||
host: process.env.DB_HOST,
|
||||
database: process.env.DB_NAME!,
|
||||
port: Number(process.env.DB_PORT),
|
||||
user: process.env.DB_USER,
|
||||
password: process.env.DB_PASSWORD,
|
||||
}
|
||||
: { uri: process.env.DB_URL },
|
||||
);
|
||||
|
||||
const db = drizzle(mysql2, {
|
||||
mode: "default",
|
||||
});
|
||||
const db = drizzle(mysql2, {
|
||||
mode: "default",
|
||||
schema: mysqlSchema,
|
||||
});
|
||||
|
||||
migrate(db, { migrationsFolder })
|
||||
await migrate(db, { migrationsFolder });
|
||||
await seedDataAsync(db as unknown as Database);
|
||||
};
|
||||
|
||||
migrateAsync()
|
||||
.then(() => {
|
||||
console.log("Migration complete");
|
||||
process.exit(0);
|
||||
|
||||
12
packages/db/migrations/run-seed.ts
Normal file
12
packages/db/migrations/run-seed.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { database } from "../driver";
|
||||
import { seedDataAsync } from "./seed";
|
||||
|
||||
seedDataAsync(database)
|
||||
.then(() => {
|
||||
console.log("Seed complete");
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log("Seed failed\n\t", err);
|
||||
process.exit(1);
|
||||
});
|
||||
52
packages/db/migrations/seed.ts
Normal file
52
packages/db/migrations/seed.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import SuperJSON from "superjson";
|
||||
|
||||
import { everyoneGroup } from "@homarr/definitions";
|
||||
import { defaultServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings";
|
||||
|
||||
import { createId, eq } from "..";
|
||||
import type { Database } from "..";
|
||||
import { groups } from "../schema/mysql";
|
||||
import { serverSettings } from "../schema/sqlite";
|
||||
|
||||
export const seedDataAsync = async (db: Database) => {
|
||||
await seedEveryoneGroupAsync(db);
|
||||
await seedServerSettingsAsync(db);
|
||||
};
|
||||
|
||||
const seedEveryoneGroupAsync = async (db: Database) => {
|
||||
const group = await db.query.groups.findFirst({
|
||||
where: eq(groups.name, everyoneGroup),
|
||||
});
|
||||
|
||||
if (group) {
|
||||
console.log("Skipping seeding of group 'everyone' as it already exists");
|
||||
return;
|
||||
}
|
||||
|
||||
await db.insert(groups).values({
|
||||
id: createId(),
|
||||
name: everyoneGroup,
|
||||
});
|
||||
console.log("Created group 'everyone' through seed");
|
||||
};
|
||||
|
||||
const seedServerSettingsAsync = async (db: Database) => {
|
||||
const serverSettingsData = await db.query.serverSettings.findMany();
|
||||
let insertedSettingsCount = 0;
|
||||
|
||||
for (const settingsKey of defaultServerSettingsKeys) {
|
||||
if (serverSettingsData.some((setting) => setting.settingKey === settingsKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await db.insert(serverSettings).values({
|
||||
settingKey: settingsKey,
|
||||
value: SuperJSON.stringify(defaultServerSettings[settingsKey]),
|
||||
});
|
||||
insertedSettingsCount++;
|
||||
}
|
||||
|
||||
if (insertedSettingsCount > 0) {
|
||||
console.info(`Inserted ${insertedSettingsCount} missing settings`);
|
||||
}
|
||||
};
|
||||
1
packages/db/migrations/sqlite/0013_faithful_hex.sql
Normal file
1
packages/db/migrations/sqlite/0013_faithful_hex.sql
Normal file
@@ -0,0 +1 @@
|
||||
CREATE UNIQUE INDEX `group_name_unique` ON `group` (`name`);
|
||||
1461
packages/db/migrations/sqlite/meta/0013_snapshot.json
Normal file
1461
packages/db/migrations/sqlite/meta/0013_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -92,6 +92,13 @@
|
||||
"when": 1729348200091,
|
||||
"tag": "0012_ambiguous_black_panther",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 13,
|
||||
"version": "6",
|
||||
"when": 1729369389386,
|
||||
"tag": "0013_faithful_hex",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -2,10 +2,27 @@ import Database from "better-sqlite3";
|
||||
import { drizzle } from "drizzle-orm/better-sqlite3";
|
||||
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
||||
|
||||
import { schema } from "../..";
|
||||
import { seedDataAsync } from "../seed";
|
||||
|
||||
const migrationsFolder = process.argv[2] ?? ".";
|
||||
|
||||
const sqlite = new Database(process.env.DB_URL?.replace("file:", ""));
|
||||
const migrateAsync = async () => {
|
||||
const sqlite = new Database(process.env.DB_URL?.replace("file:", ""));
|
||||
|
||||
const db = drizzle(sqlite);
|
||||
const db = drizzle(sqlite, { schema });
|
||||
|
||||
migrate(db, { migrationsFolder });
|
||||
migrate(db, { migrationsFolder });
|
||||
|
||||
await seedDataAsync(db);
|
||||
};
|
||||
|
||||
migrateAsync()
|
||||
.then(() => {
|
||||
console.log("Migration complete");
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log("Migration failed", err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -21,15 +21,17 @@
|
||||
"format": "prettier --check . --ignore-path ../../.gitignore",
|
||||
"lint": "eslint",
|
||||
"migration:mysql:generate": "drizzle-kit generate --config ./configs/mysql.config.ts",
|
||||
"migration:mysql:run": "drizzle-kit migrate --config ./configs/mysql.config.ts",
|
||||
"migration:mysql:run": "drizzle-kit migrate --config ./configs/mysql.config.ts && pnpm run seed",
|
||||
"migration:mysql:drop": "drizzle-kit drop --config ./configs/mysql.config.ts",
|
||||
"migration:sqlite:generate": "drizzle-kit generate --config ./configs/sqlite.config.ts",
|
||||
"migration:sqlite:run": "drizzle-kit migrate --config ./configs/sqlite.config.ts",
|
||||
"migration:sqlite:run": "drizzle-kit migrate --config ./configs/sqlite.config.ts && pnpm run seed",
|
||||
"migration:sqlite:drop": "drizzle-kit drop --config ./configs/sqlite.config.ts",
|
||||
"push:mysql": "drizzle-kit push --config ./configs/mysql.config.ts",
|
||||
"push:sqlite": "drizzle-kit push --config ./configs/sqlite.config.ts",
|
||||
"seed": "pnpm with-env tsx ./migrations/run-seed.ts",
|
||||
"studio": "drizzle-kit studio --config ./configs/sqlite.config.ts",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"typecheck": "tsc --noEmit",
|
||||
"with-env": "dotenv -e ../../.env --"
|
||||
},
|
||||
"prettier": "@homarr/prettier-config",
|
||||
"dependencies": {
|
||||
@@ -37,6 +39,7 @@
|
||||
"@homarr/common": "workspace:^0.1.0",
|
||||
"@homarr/definitions": "workspace:^0.1.0",
|
||||
"@homarr/log": "workspace:^0.1.0",
|
||||
"@homarr/server-settings": "workspace:^0.1.0",
|
||||
"@paralleldrive/cuid2": "^2.2.2",
|
||||
"@testcontainers/mysql": "^10.13.2",
|
||||
"better-sqlite3": "^11.4.0",
|
||||
@@ -53,6 +56,7 @@
|
||||
"dotenv-cli": "^7.4.2",
|
||||
"eslint": "^9.13.0",
|
||||
"prettier": "^3.3.3",
|
||||
"tsx": "4.19.1",
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,7 +120,7 @@ export const groupMembers = mysqlTable(
|
||||
|
||||
export const groups = mysqlTable("group", {
|
||||
id: varchar("id", { length: 64 }).notNull().primaryKey(),
|
||||
name: varchar("name", { length: 64 }).notNull(),
|
||||
name: varchar("name", { length: 64 }).unique().notNull(),
|
||||
ownerId: varchar("owner_id", { length: 64 }).references(() => users.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
|
||||
@@ -121,7 +121,7 @@ export const groupMembers = sqliteTable(
|
||||
|
||||
export const groups = sqliteTable("group", {
|
||||
id: text("id").notNull().primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
name: text("name").unique().notNull(),
|
||||
ownerId: text("owner_id").references(() => users.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
"clean": "rm -rf .turbo node_modules",
|
||||
"format": "prettier --check . --ignore-path ../../.gitignore",
|
||||
"lint": "eslint",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"typecheck": "tsc --noEmit",
|
||||
"postinstall": "tsx ./src/docs/codegen.ts"
|
||||
},
|
||||
"prettier": "@homarr/prettier-config",
|
||||
"dependencies": {
|
||||
|
||||
75
packages/definitions/src/docs/codegen.ts
Normal file
75
packages/definitions/src/docs/codegen.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import fs from "fs/promises";
|
||||
import path, { dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { XMLParser } from "fast-xml-parser";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createDocumentationLink } from "./index";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const removeCommonUrl = (url: string) => {
|
||||
return url.replace("https://homarr.dev", "");
|
||||
};
|
||||
|
||||
const sitemapSchema = z.object({
|
||||
urlset: z.object({
|
||||
url: z.array(
|
||||
z.object({
|
||||
loc: z.string(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
});
|
||||
|
||||
const fetchSitemapAsync = async () => {
|
||||
const response = await fetch(createDocumentationLink("/sitemap.xml"));
|
||||
return await response.text();
|
||||
};
|
||||
|
||||
const parseXml = (sitemapXml: string) => {
|
||||
const parser = new XMLParser();
|
||||
const data: unknown = parser.parse(sitemapXml);
|
||||
const result = sitemapSchema.safeParse(data);
|
||||
if (!result.success) {
|
||||
throw new Error("Invalid sitemap schema");
|
||||
}
|
||||
|
||||
return result.data;
|
||||
};
|
||||
|
||||
const mapSitemapXmlToPaths = (sitemapData: z.infer<typeof sitemapSchema>) => {
|
||||
return sitemapData.urlset.url.map((url) => removeCommonUrl(url.loc));
|
||||
};
|
||||
|
||||
const createSitemapPathType = (paths: string[]) => {
|
||||
return "export type HomarrDocumentationPath =\n" + paths.map((path) => ` | "${path.replace(/\/$/, "")}"`).join("\n");
|
||||
};
|
||||
|
||||
const updateSitemapTypeFileAsync = async (sitemapPathType: string) => {
|
||||
const content =
|
||||
"// This file is auto-generated by the codegen script\n" +
|
||||
"// it uses the sitemap.xml to generate the HomarrDocumentationPath type\n" +
|
||||
sitemapPathType +
|
||||
";\n";
|
||||
|
||||
await fs.writeFile(path.join(__dirname, "homarr-docs-sitemap.ts"), content);
|
||||
};
|
||||
|
||||
/**
|
||||
* This script fetches the sitemap.xml and generates the HomarrDocumentationPath type
|
||||
* which is used for typesafe documentation links
|
||||
*/
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
const main = async () => {
|
||||
const sitemapXml = await fetchSitemapAsync();
|
||||
const sitemapData = parseXml(sitemapXml);
|
||||
const paths = mapSitemapXmlToPaths(sitemapData);
|
||||
// Adding sitemap as it's not in the sitemap.xml and we need it for this file
|
||||
paths.push("/sitemap.xml");
|
||||
const sitemapPathType = createSitemapPathType(paths);
|
||||
await updateSitemapTypeFileAsync(sitemapPathType);
|
||||
};
|
||||
|
||||
void main();
|
||||
191
packages/definitions/src/docs/homarr-docs-sitemap.ts
Normal file
191
packages/definitions/src/docs/homarr-docs-sitemap.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
// This file is auto-generated by the codegen script
|
||||
// it uses the sitemap.xml to generate the HomarrDocumentationPath type
|
||||
export type HomarrDocumentationPath =
|
||||
| "/about-us"
|
||||
| "/blog"
|
||||
| "/blog/2023/01/11/version0.11"
|
||||
| "/blog/2023/04/16/version0.12-more-widgets"
|
||||
| "/blog/2023/11/10/authentication"
|
||||
| "/blog/2023/12/22/updated-documentation"
|
||||
| "/blog/2024/09/23/version-1.0"
|
||||
| "/blog/archive"
|
||||
| "/blog/authors"
|
||||
| "/blog/authors/ajnart"
|
||||
| "/blog/authors/manuel-rw"
|
||||
| "/blog/authors/meierschlumpf"
|
||||
| "/blog/authors/tagashi"
|
||||
| "/blog/authors/walkx"
|
||||
| "/blog/documentation-migration"
|
||||
| "/blog/tags"
|
||||
| "/blog/tags/authentication"
|
||||
| "/blog/tags/breaking-changes"
|
||||
| "/blog/tags/contributions"
|
||||
| "/blog/tags/design"
|
||||
| "/blog/tags/dnd"
|
||||
| "/blog/tags/docs"
|
||||
| "/blog/tags/documentation"
|
||||
| "/blog/tags/gridstack"
|
||||
| "/blog/tags/homarr"
|
||||
| "/blog/tags/migration"
|
||||
| "/blog/tags/notepad"
|
||||
| "/blog/tags/security"
|
||||
| "/blog/tags/translations"
|
||||
| "/blog/tags/update"
|
||||
| "/blog/tags/version"
|
||||
| "/blog/translations"
|
||||
| "/docs/tags"
|
||||
| "/docs/tags/active-directory"
|
||||
| "/docs/tags/ad-guard"
|
||||
| "/docs/tags/ad-guard-home"
|
||||
| "/docs/tags/administration"
|
||||
| "/docs/tags/advanced"
|
||||
| "/docs/tags/analytics"
|
||||
| "/docs/tags/api"
|
||||
| "/docs/tags/banner"
|
||||
| "/docs/tags/blocking"
|
||||
| "/docs/tags/board"
|
||||
| "/docs/tags/boards"
|
||||
| "/docs/tags/bookmark"
|
||||
| "/docs/tags/caddy"
|
||||
| "/docs/tags/checklist"
|
||||
| "/docs/tags/code"
|
||||
| "/docs/tags/community"
|
||||
| "/docs/tags/configuration"
|
||||
| "/docs/tags/connections"
|
||||
| "/docs/tags/customization"
|
||||
| "/docs/tags/data-sources"
|
||||
| "/docs/tags/developer"
|
||||
| "/docs/tags/development"
|
||||
| "/docs/tags/dns"
|
||||
| "/docs/tags/docker"
|
||||
| "/docs/tags/edit-mode"
|
||||
| "/docs/tags/env"
|
||||
| "/docs/tags/environment-variables"
|
||||
| "/docs/tags/feeds"
|
||||
| "/docs/tags/getting-started"
|
||||
| "/docs/tags/google"
|
||||
| "/docs/tags/grafana"
|
||||
| "/docs/tags/groups"
|
||||
| "/docs/tags/hardware"
|
||||
| "/docs/tags/health"
|
||||
| "/docs/tags/help"
|
||||
| "/docs/tags/icons"
|
||||
| "/docs/tags/iframe"
|
||||
| "/docs/tags/images"
|
||||
| "/docs/tags/installation"
|
||||
| "/docs/tags/integrade"
|
||||
| "/docs/tags/integration"
|
||||
| "/docs/tags/integrations"
|
||||
| "/docs/tags/interface"
|
||||
| "/docs/tags/jellyserr"
|
||||
| "/docs/tags/ldap"
|
||||
| "/docs/tags/links"
|
||||
| "/docs/tags/lists"
|
||||
| "/docs/tags/management"
|
||||
| "/docs/tags/monitoring"
|
||||
| "/docs/tags/news"
|
||||
| "/docs/tags/notebook"
|
||||
| "/docs/tags/notes"
|
||||
| "/docs/tags/oidc"
|
||||
| "/docs/tags/open-media-vault"
|
||||
| "/docs/tags/overseerr"
|
||||
| "/docs/tags/permissions"
|
||||
| "/docs/tags/pi-hole"
|
||||
| "/docs/tags/preferences"
|
||||
| "/docs/tags/programming"
|
||||
| "/docs/tags/proxmox"
|
||||
| "/docs/tags/proxy"
|
||||
| "/docs/tags/roles"
|
||||
| "/docs/tags/rss"
|
||||
| "/docs/tags/search"
|
||||
| "/docs/tags/search-engines"
|
||||
| "/docs/tags/security"
|
||||
| "/docs/tags/seo"
|
||||
| "/docs/tags/server"
|
||||
| "/docs/tags/settings"
|
||||
| "/docs/tags/sinkhole"
|
||||
| "/docs/tags/sso"
|
||||
| "/docs/tags/system"
|
||||
| "/docs/tags/table"
|
||||
| "/docs/tags/technical-documentation"
|
||||
| "/docs/tags/text"
|
||||
| "/docs/tags/theming"
|
||||
| "/docs/tags/traefik"
|
||||
| "/docs/tags/translations"
|
||||
| "/docs/tags/unraid"
|
||||
| "/docs/tags/user"
|
||||
| "/docs/tags/users"
|
||||
| "/docs/tags/variables"
|
||||
| "/docs/tags/widgets"
|
||||
| "/docs/advanced/command-line"
|
||||
| "/docs/advanced/command-line/password-recovery"
|
||||
| "/docs/advanced/configuration/environment-variables"
|
||||
| "/docs/advanced/configuration/keyboard-shortcuts"
|
||||
| "/docs/advanced/configuration/proxies-and-certificates"
|
||||
| "/docs/advanced/customizations/board-customization"
|
||||
| "/docs/advanced/customizations/dark-mode"
|
||||
| "/docs/advanced/customizations/icons"
|
||||
| "/docs/advanced/customizations/user-preferences"
|
||||
| "/docs/advanced/sso"
|
||||
| "/docs/category/advanced"
|
||||
| "/docs/category/getting-started"
|
||||
| "/docs/category/installation"
|
||||
| "/docs/category/installation-1"
|
||||
| "/docs/category/integrations"
|
||||
| "/docs/category/management"
|
||||
| "/docs/category/more"
|
||||
| "/docs/category/widgets"
|
||||
| "/docs/community/developer-guides"
|
||||
| "/docs/community/donate"
|
||||
| "/docs/community/faq"
|
||||
| "/docs/community/get-in-touch"
|
||||
| "/docs/community/license"
|
||||
| "/docs/community/translations"
|
||||
| "/docs/getting-started"
|
||||
| "/docs/getting-started/after-the-installation"
|
||||
| "/docs/getting-started/glossary"
|
||||
| "/docs/getting-started/installation/docker"
|
||||
| "/docs/getting-started/installation/easy-panel"
|
||||
| "/docs/getting-started/installation/home-assistant"
|
||||
| "/docs/getting-started/installation/kubernetes"
|
||||
| "/docs/getting-started/installation/portainer"
|
||||
| "/docs/getting-started/installation/qnap"
|
||||
| "/docs/getting-started/installation/saltbox"
|
||||
| "/docs/getting-started/installation/source"
|
||||
| "/docs/getting-started/installation/synology"
|
||||
| "/docs/getting-started/installation/truenas"
|
||||
| "/docs/getting-started/installation/unraid"
|
||||
| "/docs/integrations/containers"
|
||||
| "/docs/integrations/dns"
|
||||
| "/docs/integrations/hardware"
|
||||
| "/docs/integrations/media-requester"
|
||||
| "/docs/integrations/media-server"
|
||||
| "/docs/integrations/servarr"
|
||||
| "/docs/integrations/torrent"
|
||||
| "/docs/integrations/usenet"
|
||||
| "/docs/management/api"
|
||||
| "/docs/management/boards"
|
||||
| "/docs/management/integrations"
|
||||
| "/docs/management/search-engines"
|
||||
| "/docs/management/settings"
|
||||
| "/docs/management/users"
|
||||
| "/docs/widgets/bookmarks"
|
||||
| "/docs/widgets/calendar-widget"
|
||||
| "/docs/widgets/clock-widget"
|
||||
| "/docs/widgets/dashdot-widget"
|
||||
| "/docs/widgets/dns-hole"
|
||||
| "/docs/widgets/download-speed-widget"
|
||||
| "/docs/widgets/health-monitoring"
|
||||
| "/docs/widgets/home-assistant"
|
||||
| "/docs/widgets/iframe"
|
||||
| "/docs/widgets/indexer-manager"
|
||||
| "/docs/widgets/media-requests"
|
||||
| "/docs/widgets/media-server"
|
||||
| "/docs/widgets/notebook"
|
||||
| "/docs/widgets/rss-widget"
|
||||
| "/docs/widgets/torrent-widget"
|
||||
| "/docs/widgets/usenet-widget"
|
||||
| "/docs/widgets/video"
|
||||
| "/docs/widgets/weather-widget"
|
||||
| ""
|
||||
| "/sitemap.xml";
|
||||
7
packages/definitions/src/docs/index.ts
Normal file
7
packages/definitions/src/docs/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { HomarrDocumentationPath } from "./homarr-docs-sitemap";
|
||||
|
||||
const documentationBaseUrl = "https://deploy-preview-113--homarr-docs.netlify.app";
|
||||
|
||||
// Please use the method so the path can be checked!
|
||||
export const createDocumentationLink = (path: HomarrDocumentationPath, hashTag?: `#${string}`) =>
|
||||
`${documentationBaseUrl}${path}${hashTag ?? ""}`;
|
||||
1
packages/definitions/src/group.ts
Normal file
1
packages/definitions/src/group.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const everyoneGroup = "everyone";
|
||||
@@ -6,3 +6,5 @@ export * from "./permissions";
|
||||
export * from "./docker";
|
||||
export * from "./auth";
|
||||
export * from "./user";
|
||||
export * from "./group";
|
||||
export * from "./docs";
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Group, Kbd, Text } from "@mantine/core";
|
||||
import { IconBook2, IconBrandDiscord, IconBrandGithub } from "@tabler/icons-react";
|
||||
|
||||
import { createDocumentationLink } from "@homarr/definitions";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { createGroup } from "../lib/group";
|
||||
@@ -45,7 +46,7 @@ const helpMode = {
|
||||
{
|
||||
label: t("documentation.label"),
|
||||
icon: IconBook2,
|
||||
href: "https://homarr.dev/docs/getting-started/",
|
||||
href: createDocumentationLink("/docs/getting-started"),
|
||||
},
|
||||
{
|
||||
label: t("submitIssue.label"),
|
||||
|
||||
@@ -249,6 +249,9 @@ export default {
|
||||
mixed: "Some members are from external providers and cannot be managed here",
|
||||
external: "All members are from external providers and cannot be managed here",
|
||||
},
|
||||
reservedNotice: {
|
||||
message: "This group is reserved for system use and restricts some actions. {checkoutDocs}",
|
||||
},
|
||||
action: {
|
||||
create: {
|
||||
label: "New group",
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { groupPermissionKeys } from "@homarr/definitions";
|
||||
import { everyoneGroup, groupPermissionKeys } from "@homarr/definitions";
|
||||
|
||||
import { byIdSchema } from "./common";
|
||||
import { zodEnumFromArray } from "./enums";
|
||||
|
||||
const createSchema = z.object({
|
||||
name: z.string().trim().min(1).max(64),
|
||||
name: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(64)
|
||||
.refine((value) => value !== everyoneGroup, {
|
||||
message: "'everyone' is a reserved group name",
|
||||
}),
|
||||
});
|
||||
|
||||
const updateSchema = createSchema.merge(byIdSchema);
|
||||
|
||||
@@ -13,6 +13,8 @@ import classes from "./component.module.css";
|
||||
|
||||
import "video.js/dist/video-js.css";
|
||||
|
||||
import { createDocumentationLink } from "@homarr/definitions";
|
||||
|
||||
export default function VideoWidget({ options }: WidgetComponentProps<"video">) {
|
||||
if (options.feedUrl.trim() === "") {
|
||||
return <NoUrl />;
|
||||
@@ -46,7 +48,7 @@ const ForYoutubeUseIframe = () => {
|
||||
<Stack align="center" gap="xs">
|
||||
<IconBrandYoutube />
|
||||
<Title order={4}>{t("widget.video.error.forYoutubeUseIframe")}</Title>
|
||||
<Anchor href="https://homarr.dev/docs/widgets/iframe/">{t("common.action.checkoutDocs")}</Anchor>
|
||||
<Anchor href={createDocumentationLink("/docs/widgets/iframe")}>{t("common.action.checkoutDocs")}</Anchor>
|
||||
</Stack>
|
||||
</Center>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user