Merge branch 'dev' into ajnart/fix-duplicate-users
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { appRouter as innerAppRouter } from "./router/app";
|
||||
import { boardRouter } from "./router/board";
|
||||
import { groupRouter } from "./router/group";
|
||||
import { homeRouter } from "./router/home";
|
||||
import { iconsRouter } from "./router/icons";
|
||||
import { integrationRouter } from "./router/integration";
|
||||
import { inviteRouter } from "./router/invite";
|
||||
@@ -21,6 +22,7 @@ export const appRouter = createTRPCRouter({
|
||||
location: locationRouter,
|
||||
log: logRouter,
|
||||
icon: iconsRouter,
|
||||
home: homeRouter,
|
||||
});
|
||||
|
||||
// export type definition of API
|
||||
|
||||
31
packages/api/src/router/home.ts
Normal file
31
packages/api/src/router/home.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { count } from "@homarr/db";
|
||||
import {
|
||||
apps,
|
||||
boards,
|
||||
groups,
|
||||
integrations,
|
||||
invites,
|
||||
users,
|
||||
} from "@homarr/db/schema/sqlite";
|
||||
|
||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||
|
||||
export const homeRouter = createTRPCRouter({
|
||||
getStats: protectedProcedure.query(async ({ ctx }) => {
|
||||
return {
|
||||
countBoards:
|
||||
(await ctx.db.select({ count: count() }).from(boards))[0]?.count ?? 0,
|
||||
countUsers:
|
||||
(await ctx.db.select({ count: count() }).from(users))[0]?.count ?? 0,
|
||||
countGroups:
|
||||
(await ctx.db.select({ count: count() }).from(groups))[0]?.count ?? 0,
|
||||
countInvites:
|
||||
(await ctx.db.select({ count: count() }).from(invites))[0]?.count ?? 0,
|
||||
countIntegrations:
|
||||
(await ctx.db.select({ count: count() }).from(integrations))[0]
|
||||
?.count ?? 0,
|
||||
countApps:
|
||||
(await ctx.db.select({ count: count() }).from(apps))[0]?.count ?? 0,
|
||||
};
|
||||
}),
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it, test, vi } from "vitest";
|
||||
|
||||
import type { Session } from "@homarr/auth";
|
||||
import { createId, eq, schema } from "@homarr/db";
|
||||
import { users } from "@homarr/db/schema/sqlite";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
|
||||
import { userRouter } from "../user";
|
||||
@@ -91,7 +92,106 @@ describe("initUser should initialize the first user", () => {
|
||||
|
||||
await expect(act()).rejects.toThrow("too_small");
|
||||
});
|
||||
});
|
||||
|
||||
describe("register should create a user with valid invitation", () => {
|
||||
test("register should create a user with valid invitation", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = userRouter.createCaller({
|
||||
db,
|
||||
session: null,
|
||||
});
|
||||
|
||||
const userId = createId();
|
||||
const inviteId = createId();
|
||||
const inviteToken = "123";
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date(2024, 0, 3));
|
||||
|
||||
await db.insert(users).values({
|
||||
id: userId,
|
||||
});
|
||||
await db.insert(schema.invites).values({
|
||||
id: inviteId,
|
||||
token: inviteToken,
|
||||
creatorId: userId,
|
||||
expirationDate: new Date(2024, 0, 5),
|
||||
});
|
||||
|
||||
// Act
|
||||
await caller.register({
|
||||
inviteId,
|
||||
token: inviteToken,
|
||||
username: "test",
|
||||
password: "12345678",
|
||||
confirmPassword: "12345678",
|
||||
});
|
||||
|
||||
// Assert
|
||||
const user = await db.query.users.findMany({
|
||||
columns: {
|
||||
name: true,
|
||||
},
|
||||
});
|
||||
const invite = await db.query.invites.findMany({
|
||||
columns: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(user).toHaveLength(2);
|
||||
expect(invite).toHaveLength(0);
|
||||
});
|
||||
|
||||
test.each([
|
||||
[{ token: "fakeToken" }, new Date(2024, 0, 3)],
|
||||
[{ inviteId: "fakeInviteId" }, new Date(2024, 0, 3)],
|
||||
[{}, new Date(2024, 0, 5, 0, 0, 1)],
|
||||
])(
|
||||
"register should throw an error with input %s and date %s if the invitation is invalid",
|
||||
async (partialInput, systemTime) => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = userRouter.createCaller({
|
||||
db,
|
||||
session: null,
|
||||
});
|
||||
|
||||
const userId = createId();
|
||||
const inviteId = createId();
|
||||
const inviteToken = "123";
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(systemTime);
|
||||
|
||||
await db.insert(users).values({
|
||||
id: userId,
|
||||
});
|
||||
await db.insert(schema.invites).values({
|
||||
id: inviteId,
|
||||
token: inviteToken,
|
||||
creatorId: userId,
|
||||
expirationDate: new Date(2024, 0, 5),
|
||||
});
|
||||
|
||||
// Act
|
||||
const act = async () =>
|
||||
await caller.register({
|
||||
inviteId,
|
||||
token: inviteToken,
|
||||
username: "test",
|
||||
password: "12345678",
|
||||
confirmPassword: "12345678",
|
||||
...partialInput,
|
||||
});
|
||||
|
||||
// Assert
|
||||
await expect(act()).rejects.toThrow("Invalid invite");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("editProfile shoud update user", () => {
|
||||
test("editProfile should update users and not update emailVerified when email not dirty", async () => {
|
||||
// arrange
|
||||
const db = createDb();
|
||||
@@ -112,11 +212,9 @@ describe("initUser should initialize the first user", () => {
|
||||
|
||||
// act
|
||||
await caller.editProfile({
|
||||
userId: id,
|
||||
form: {
|
||||
name: "ABC",
|
||||
email: "",
|
||||
},
|
||||
id: id,
|
||||
name: "ABC",
|
||||
email: "",
|
||||
});
|
||||
|
||||
// assert
|
||||
@@ -156,11 +254,9 @@ describe("initUser should initialize the first user", () => {
|
||||
|
||||
// act
|
||||
await caller.editProfile({
|
||||
userId: id,
|
||||
form: {
|
||||
name: "ABC",
|
||||
email: "myNewEmail@gmail.com",
|
||||
},
|
||||
id,
|
||||
name: "ABC",
|
||||
email: "myNewEmail@gmail.com",
|
||||
});
|
||||
|
||||
// assert
|
||||
@@ -180,7 +276,9 @@ describe("initUser should initialize the first user", () => {
|
||||
image: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("delete should delete user", () => {
|
||||
test("delete should delete user", async () => {
|
||||
const db = createDb();
|
||||
const caller = userRouter.createCaller({
|
||||
|
||||
@@ -3,12 +3,12 @@ import { observable } from "@trpc/server/observable";
|
||||
|
||||
import { createSalt, hashPassword } from "@homarr/auth";
|
||||
import type { Database } from "@homarr/db";
|
||||
import { createId, eq, schema } from "@homarr/db";
|
||||
import { users } from "@homarr/db/schema/sqlite";
|
||||
import { and, createId, eq, schema } from "@homarr/db";
|
||||
import { invites, users } from "@homarr/db/schema/sqlite";
|
||||
import { exampleChannel } from "@homarr/redis";
|
||||
import { validation, z } from "@homarr/validation";
|
||||
|
||||
import { createTRPCRouter, publicProcedure } from "../trpc";
|
||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
|
||||
|
||||
export const userRouter = createTRPCRouter({
|
||||
initUser: publicProcedure
|
||||
@@ -29,19 +29,86 @@ export const userRouter = createTRPCRouter({
|
||||
|
||||
await createUser(ctx.db, input);
|
||||
}),
|
||||
register: publicProcedure
|
||||
.input(validation.user.registrationApi)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const inviteWhere = and(
|
||||
eq(invites.id, input.inviteId),
|
||||
eq(invites.token, input.token),
|
||||
);
|
||||
const dbInvite = await ctx.db.query.invites.findFirst({
|
||||
columns: {
|
||||
id: true,
|
||||
expirationDate: true,
|
||||
},
|
||||
where: inviteWhere,
|
||||
});
|
||||
|
||||
if (!dbInvite || dbInvite.expirationDate < new Date()) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Invalid invite",
|
||||
});
|
||||
}
|
||||
|
||||
await checkUsernameAlreadyTakenAndThrowAsync(ctx.db, input.username);
|
||||
|
||||
await createUser(ctx.db, input);
|
||||
// Delete invite as it's used
|
||||
await ctx.db.delete(invites).where(inviteWhere);
|
||||
}),
|
||||
create: publicProcedure
|
||||
.input(validation.user.create)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const user = await ctx.db.query.users.findFirst({
|
||||
where: eq(users.name, input.username.toLowerCase()),
|
||||
});
|
||||
if (user !== undefined) {
|
||||
await checkUsernameAlreadyTakenAndThrowAsync(ctx.db, input.username);
|
||||
|
||||
await createUser(ctx.db, input);
|
||||
}),
|
||||
setProfileImage: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
userId: z.string(),
|
||||
// Max image size of 256KB, only png and jpeg are allowed
|
||||
image: z
|
||||
.string()
|
||||
.regex(/^data:image\/(png|jpeg|gif|webp);base64,[A-Za-z0-9/+]+=*$/g)
|
||||
.max(262144)
|
||||
.nullable(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
// Only admins can change other users profile images
|
||||
if (
|
||||
ctx.session.user.id !== input.userId &&
|
||||
!ctx.session.user.permissions.includes("admin")
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "CONFLICT",
|
||||
message: "User already exists",
|
||||
code: "FORBIDDEN",
|
||||
message: "You are not allowed to change other users profile images",
|
||||
});
|
||||
}
|
||||
await createUser(ctx.db, input);
|
||||
|
||||
const user = await ctx.db.query.users.findFirst({
|
||||
columns: {
|
||||
id: true,
|
||||
image: true,
|
||||
},
|
||||
where: eq(users.id, input.userId),
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "User not found",
|
||||
});
|
||||
}
|
||||
|
||||
await ctx.db
|
||||
.update(users)
|
||||
.set({
|
||||
image: input.image,
|
||||
})
|
||||
.where(eq(users.id, input.userId));
|
||||
}),
|
||||
getAll: publicProcedure.query(async ({ ctx }) => {
|
||||
return ctx.db.query.users.findMany({
|
||||
@@ -66,7 +133,7 @@ export const userRouter = createTRPCRouter({
|
||||
getById: publicProcedure
|
||||
.input(z.object({ userId: z.string() }))
|
||||
.query(async ({ input, ctx }) => {
|
||||
return ctx.db.query.users.findFirst({
|
||||
const user = await ctx.db.query.users.findFirst({
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
@@ -76,47 +143,96 @@ export const userRouter = createTRPCRouter({
|
||||
},
|
||||
where: eq(users.id, input.userId),
|
||||
});
|
||||
}),
|
||||
editProfile: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
form: validation.user.editProfile,
|
||||
userId: z.string(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const user = await ctx.db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, input.userId))
|
||||
.limit(1);
|
||||
const existingUser = await ctx.db.query.users.findFirst({
|
||||
where: eq(users.name, input.form.name.toLowerCase()),
|
||||
});
|
||||
|
||||
if (existingUser !== undefined) {
|
||||
if (!user) {
|
||||
throw new TRPCError({
|
||||
code: "CONFLICT",
|
||||
message: `User ${input.form.name} already exists`,
|
||||
code: "NOT_FOUND",
|
||||
message: "User not found",
|
||||
});
|
||||
}
|
||||
const emailDirty =
|
||||
input.form.email && user[0]?.email !== input.form.email;
|
||||
|
||||
return user;
|
||||
}),
|
||||
editProfile: publicProcedure
|
||||
.input(validation.user.editProfile)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const user = await ctx.db.query.users.findFirst({
|
||||
columns: { email: true },
|
||||
where: eq(users.id, input.id),
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "User not found",
|
||||
});
|
||||
}
|
||||
|
||||
await checkUsernameAlreadyTakenAndThrowAsync(
|
||||
ctx.db,
|
||||
input.name,
|
||||
input.id,
|
||||
);
|
||||
|
||||
const emailDirty = input.email && user.email !== input.email;
|
||||
await ctx.db
|
||||
.update(users)
|
||||
.set({
|
||||
name: input.form.name,
|
||||
email: emailDirty === true ? input.form.email : undefined,
|
||||
name: input.name,
|
||||
email: emailDirty === true ? input.email : undefined,
|
||||
emailVerified: emailDirty === true ? null : undefined,
|
||||
})
|
||||
.where(eq(users.id, input.userId));
|
||||
.where(eq(users.id, input.id));
|
||||
}),
|
||||
delete: publicProcedure.input(z.string()).mutation(async ({ input, ctx }) => {
|
||||
await ctx.db.delete(users).where(eq(users.id, input));
|
||||
}),
|
||||
changePassword: publicProcedure
|
||||
.input(validation.user.changePassword)
|
||||
changePassword: protectedProcedure
|
||||
.input(validation.user.changePasswordApi)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const user = ctx.session.user;
|
||||
// Only admins can change other users' passwords
|
||||
if (!user.permissions.includes("admin") && user.id !== input.userId) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "User not found",
|
||||
});
|
||||
}
|
||||
|
||||
// Admins can change the password of other users without providing the previous password
|
||||
const isPreviousPasswordRequired = ctx.session.user.id === input.userId;
|
||||
|
||||
if (isPreviousPasswordRequired) {
|
||||
const dbUser = await ctx.db.query.users.findFirst({
|
||||
columns: {
|
||||
id: true,
|
||||
password: true,
|
||||
salt: true,
|
||||
},
|
||||
where: eq(users.id, input.userId),
|
||||
});
|
||||
|
||||
if (!dbUser) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "User not found",
|
||||
});
|
||||
}
|
||||
|
||||
const previousPasswordHash = await hashPassword(
|
||||
input.previousPassword,
|
||||
dbUser.salt ?? "",
|
||||
);
|
||||
const isValid = previousPasswordHash === dbUser.password;
|
||||
|
||||
if (!isValid) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Invalid password",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const salt = await createSalt();
|
||||
const hashedPassword = await hashPassword(input.password, salt);
|
||||
await ctx.db
|
||||
@@ -155,3 +271,21 @@ const createUser = async (
|
||||
salt,
|
||||
});
|
||||
};
|
||||
|
||||
const checkUsernameAlreadyTakenAndThrowAsync = async (
|
||||
db: Database,
|
||||
username: string,
|
||||
ignoreId?: string,
|
||||
) => {
|
||||
const user = await db.query.users.findFirst({
|
||||
where: eq(users.name, username.toLowerCase()),
|
||||
});
|
||||
|
||||
if (!user) return;
|
||||
if (ignoreId && user.id === ignoreId) return;
|
||||
|
||||
throw new TRPCError({
|
||||
code: "CONFLICT",
|
||||
message: "Username already taken",
|
||||
});
|
||||
};
|
||||
|
||||
7
packages/common/src/array.ts
Normal file
7
packages/common/src/array.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export const splitToNChunks = <T>(array: T[], chunks: number): T[][] => {
|
||||
const result: T[][] = [];
|
||||
for (let i = chunks; i > 0; i--) {
|
||||
result.push(array.splice(0, Math.ceil(array.length / i)));
|
||||
}
|
||||
return result;
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from "./object";
|
||||
export * from "./string";
|
||||
export * from "./cookie";
|
||||
export * from "./array";
|
||||
export * from "./stopwatch";
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
"migration:mysql:run": "drizzle-kit migrate --config ./configs/mysql.config.ts",
|
||||
"push:sqlite": "drizzle-kit push --config ./configs/sqlite.config.ts",
|
||||
"push:mysql": "drizzle-kit push --config ./configs/mysql.config.ts",
|
||||
"studio": "drizzle-kit studio",
|
||||
"studio": "drizzle-kit studio --config ./configs/sqlite.config.ts",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -33,10 +33,10 @@
|
||||
"@homarr/definitions": "workspace:^0.1.0",
|
||||
"@homarr/log": "workspace:^0.1.0",
|
||||
"@paralleldrive/cuid2": "^2.2.2",
|
||||
"better-sqlite3": "^9.6.0",
|
||||
"better-sqlite3": "^10.0.0",
|
||||
"drizzle-orm": "^0.30.10",
|
||||
"mysql2": "3.9.7",
|
||||
"drizzle-kit": "^0.21.1"
|
||||
"drizzle-kit": "^0.21.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
|
||||
5
packages/redis/redis.conf
Normal file
5
packages/redis/redis.conf
Normal file
@@ -0,0 +1,5 @@
|
||||
# Directory to store dump.rdb
|
||||
dir /appdata/redis
|
||||
|
||||
# Save the data to disk every 60 seconds if at least 1 key changed
|
||||
save 60 1
|
||||
@@ -2,11 +2,18 @@ import "dayjs/locale/en";
|
||||
|
||||
export default {
|
||||
user: {
|
||||
title: "Users",
|
||||
name: "User",
|
||||
page: {
|
||||
login: {
|
||||
title: "Log in to your account",
|
||||
subtitle: "Welcome back! Please enter your credentials",
|
||||
},
|
||||
invite: {
|
||||
title: "Join Homarr",
|
||||
subtitle: "Welcome to Homarr! Please create your account",
|
||||
description: "You were invited by {username}",
|
||||
},
|
||||
init: {
|
||||
title: "New Homarr installation",
|
||||
subtitle: "Please create the initial administator user",
|
||||
@@ -25,10 +32,98 @@ export default {
|
||||
passwordConfirm: {
|
||||
label: "Confirm password",
|
||||
},
|
||||
previousPassword: {
|
||||
label: "Previous password",
|
||||
},
|
||||
},
|
||||
error: {
|
||||
usernameTaken: "Username already taken",
|
||||
},
|
||||
action: {
|
||||
login: "Login",
|
||||
login: {
|
||||
label: "Login",
|
||||
notification: {
|
||||
success: {
|
||||
title: "Login successful",
|
||||
message: "You are now logged in",
|
||||
},
|
||||
error: {
|
||||
title: "Login failed",
|
||||
message: "Your login failed",
|
||||
},
|
||||
},
|
||||
},
|
||||
register: {
|
||||
label: "Create account",
|
||||
notification: {
|
||||
success: {
|
||||
title: "Account created",
|
||||
message: "Please log in to continue",
|
||||
},
|
||||
error: {
|
||||
title: "Account creation failed",
|
||||
message: "Your account could not be created",
|
||||
},
|
||||
},
|
||||
},
|
||||
create: "Create user",
|
||||
changePassword: {
|
||||
label: "Change password",
|
||||
notification: {
|
||||
success: {
|
||||
message: "Password changed successfully",
|
||||
},
|
||||
error: {
|
||||
message: "Unable to change password",
|
||||
},
|
||||
},
|
||||
},
|
||||
manageAvatar: {
|
||||
changeImage: {
|
||||
label: "Change image",
|
||||
notification: {
|
||||
success: {
|
||||
message: "The image changed successfully",
|
||||
},
|
||||
error: {
|
||||
message: "Unable to change image",
|
||||
},
|
||||
toLarge: {
|
||||
title: "Image is too large",
|
||||
message: "Max image size is {size}",
|
||||
},
|
||||
},
|
||||
},
|
||||
removeImage: {
|
||||
label: "Remove image",
|
||||
confirm: "Are you sure you want to remove the image?",
|
||||
notification: {
|
||||
success: {
|
||||
message: "Image removed successfully",
|
||||
},
|
||||
error: {
|
||||
message: "Unable to remove image",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
editProfile: {
|
||||
notification: {
|
||||
success: {
|
||||
message: "Profile updated successfully",
|
||||
},
|
||||
error: {
|
||||
message: "Unable to update profile",
|
||||
},
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
label: "Delete user permanently",
|
||||
description:
|
||||
"Deletes this user including their preferences. Will not delete any boards. User will not be notified.",
|
||||
confirm:
|
||||
"Are you sure, that you want to delete the user {username} with his preferences?",
|
||||
},
|
||||
select: {
|
||||
label: "Select user",
|
||||
notFound: "No user found",
|
||||
@@ -109,10 +204,10 @@ export default {
|
||||
label: "New group",
|
||||
notification: {
|
||||
success: {
|
||||
message: "The app was successfully created",
|
||||
message: "The group was successfully created",
|
||||
},
|
||||
error: {
|
||||
message: "The app could not be created",
|
||||
message: "The group could not be created",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -353,6 +448,7 @@ export default {
|
||||
save: "Save",
|
||||
saveChanges: "Save changes",
|
||||
cancel: "Cancel",
|
||||
delete: "Delete",
|
||||
discard: "Discard",
|
||||
confirm: "Confirm",
|
||||
continue: "Continue",
|
||||
@@ -406,19 +502,14 @@ export default {
|
||||
switchToDarkMode: "Switch to dark mode",
|
||||
switchToLightMode: "Switch to light mode",
|
||||
management: "Management",
|
||||
preferences: "Your preferences",
|
||||
logout: "Logout",
|
||||
login: "Login",
|
||||
navigateDefaultBoard: "Navigate to default board",
|
||||
loggedOut: "Logged out",
|
||||
},
|
||||
},
|
||||
menu: {
|
||||
section: {
|
||||
dangerZone: {
|
||||
title: "Danger Zone",
|
||||
},
|
||||
},
|
||||
},
|
||||
dangerZone: "Danger zone",
|
||||
noResults: "No results found",
|
||||
preview: {
|
||||
show: "Show preview",
|
||||
@@ -472,7 +563,6 @@ export default {
|
||||
menu: {
|
||||
label: {
|
||||
settings: "Settings",
|
||||
dangerZone: "Danger Zone",
|
||||
},
|
||||
},
|
||||
create: {
|
||||
@@ -917,7 +1007,7 @@ export default {
|
||||
},
|
||||
},
|
||||
dangerZone: {
|
||||
title: "Danger Zone",
|
||||
title: "Danger zone",
|
||||
action: {
|
||||
rename: {
|
||||
label: "Rename board",
|
||||
@@ -1012,6 +1102,22 @@ export default {
|
||||
},
|
||||
},
|
||||
page: {
|
||||
home: {
|
||||
statistic: {
|
||||
countBoards: "Boards",
|
||||
createUser: "Create new user",
|
||||
createInvite: "Create new invite",
|
||||
addIntegration: "Create integration",
|
||||
addApp: "Add app",
|
||||
manageRoles: "Manage roles",
|
||||
},
|
||||
statisticLabel: {
|
||||
boards: "Boards",
|
||||
resources: "Resources",
|
||||
authentication: "Authentication",
|
||||
authorization: "Authorization",
|
||||
},
|
||||
},
|
||||
board: {
|
||||
title: "Your boards",
|
||||
action: {
|
||||
@@ -1047,46 +1153,21 @@ export default {
|
||||
},
|
||||
},
|
||||
user: {
|
||||
back: "Back to users",
|
||||
setting: {
|
||||
general: {
|
||||
title: "General",
|
||||
},
|
||||
security: {
|
||||
title: "Security",
|
||||
},
|
||||
},
|
||||
list: {
|
||||
metaTitle: "Manage users",
|
||||
title: "Users",
|
||||
},
|
||||
edit: {
|
||||
metaTitle: "Edit user {username}",
|
||||
section: {
|
||||
profile: {
|
||||
title: "Profile",
|
||||
editProfile: {
|
||||
title: "Edit profile",
|
||||
message: {
|
||||
profileUpdated: "Updated profile",
|
||||
},
|
||||
},
|
||||
},
|
||||
preferences: {
|
||||
title: "Preferences",
|
||||
},
|
||||
security: {
|
||||
title: "Security",
|
||||
changePassword: {
|
||||
title: "Change password",
|
||||
message: {
|
||||
passwordUpdated: "Updated password",
|
||||
},
|
||||
},
|
||||
},
|
||||
dangerZone: {
|
||||
title: "Danger zone",
|
||||
action: {
|
||||
delete: {
|
||||
label: "Delete user permanently",
|
||||
description:
|
||||
"Deletes this user including their preferences. Will not delete any boards. User will not be notified.",
|
||||
button: "Delete",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
create: {
|
||||
metaTitle: "Create user",
|
||||
@@ -1159,7 +1240,6 @@ export default {
|
||||
setting: {
|
||||
general: {
|
||||
title: "General",
|
||||
dangerZone: "Danger zone",
|
||||
},
|
||||
members: {
|
||||
title: "Members",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { AvatarProps } from "@mantine/core";
|
||||
import { Avatar } from "@mantine/core";
|
||||
import type { AvatarProps, MantineSize } from "@mantine/core";
|
||||
|
||||
export interface UserProps {
|
||||
name: string | null;
|
||||
@@ -8,7 +8,7 @@ export interface UserProps {
|
||||
|
||||
interface UserAvatarProps {
|
||||
user: UserProps | null;
|
||||
size: MantineSize;
|
||||
size: AvatarProps["size"];
|
||||
}
|
||||
|
||||
export const UserAvatar = ({ user, size }: UserAvatarProps) => {
|
||||
|
||||
@@ -22,7 +22,26 @@ const signInSchema = z.object({
|
||||
password: z.string(),
|
||||
});
|
||||
|
||||
const registrationSchema = z
|
||||
.object({
|
||||
username: usernameSchema,
|
||||
password: passwordSchema,
|
||||
confirmPassword: z.string(),
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
path: ["confirmPassword"],
|
||||
message: "Passwords do not match",
|
||||
});
|
||||
|
||||
const registrationSchemaApi = registrationSchema.and(
|
||||
z.object({
|
||||
inviteId: z.string(),
|
||||
token: z.string(),
|
||||
}),
|
||||
);
|
||||
|
||||
const editProfileSchema = z.object({
|
||||
id: z.string(),
|
||||
name: usernameSchema,
|
||||
email: z
|
||||
.string()
|
||||
@@ -33,16 +52,29 @@ const editProfileSchema = z.object({
|
||||
.nullable(),
|
||||
});
|
||||
|
||||
const changePasswordSchema = z.object({
|
||||
userId: z.string(),
|
||||
password: passwordSchema,
|
||||
});
|
||||
const changePasswordSchema = z
|
||||
.object({
|
||||
previousPassword: z.string(),
|
||||
password: passwordSchema,
|
||||
confirmPassword: z.string(),
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
path: ["confirmPassword"],
|
||||
message: "Passwords do not match",
|
||||
});
|
||||
|
||||
const changePasswordApiSchema = changePasswordSchema.and(
|
||||
z.object({ userId: z.string() }),
|
||||
);
|
||||
|
||||
export const userSchemas = {
|
||||
signIn: signInSchema,
|
||||
registration: registrationSchema,
|
||||
registrationApi: registrationSchemaApi,
|
||||
init: initUserSchema,
|
||||
create: createUserSchema,
|
||||
password: passwordSchema,
|
||||
editProfile: editProfileSchema,
|
||||
changePassword: changePasswordSchema,
|
||||
changePasswordApi: changePasswordApiSchema,
|
||||
};
|
||||
|
||||
@@ -45,21 +45,21 @@
|
||||
"@homarr/translation": "workspace:^0.1.0",
|
||||
"@homarr/ui": "workspace:^0.1.0",
|
||||
"@homarr/validation": "workspace:^0.1.0",
|
||||
"@tiptap/extension-link": "^2.3.2",
|
||||
"@tiptap/react": "^2.3.2",
|
||||
"@tiptap/starter-kit": "^2.3.2",
|
||||
"@tiptap/extension-color": "2.3.2",
|
||||
"@tiptap/extension-highlight": "2.3.2",
|
||||
"@tiptap/extension-image": "2.3.2",
|
||||
"@tiptap/extension-table": "2.3.2",
|
||||
"@tiptap/extension-table-cell": "2.3.2",
|
||||
"@tiptap/extension-table-header": "2.3.2",
|
||||
"@tiptap/extension-table-row": "2.3.2",
|
||||
"@tiptap/extension-task-item": "2.3.2",
|
||||
"@tiptap/extension-task-list": "2.3.2",
|
||||
"@tiptap/extension-text-align": "2.3.2",
|
||||
"@tiptap/extension-text-style": "2.3.2",
|
||||
"@tiptap/extension-underline": "2.3.2",
|
||||
"@tiptap/extension-link": "^2.4.0",
|
||||
"@tiptap/react": "^2.4.0",
|
||||
"@tiptap/starter-kit": "^2.4.0",
|
||||
"@tiptap/extension-color": "2.4.0",
|
||||
"@tiptap/extension-highlight": "2.4.0",
|
||||
"@tiptap/extension-image": "2.4.0",
|
||||
"@tiptap/extension-table": "2.4.0",
|
||||
"@tiptap/extension-table-cell": "2.4.0",
|
||||
"@tiptap/extension-table-header": "2.4.0",
|
||||
"@tiptap/extension-table-row": "2.4.0",
|
||||
"@tiptap/extension-task-item": "2.4.0",
|
||||
"@tiptap/extension-task-list": "2.4.0",
|
||||
"@tiptap/extension-text-align": "2.4.0",
|
||||
"@tiptap/extension-text-style": "2.4.0",
|
||||
"@tiptap/extension-underline": "2.4.0",
|
||||
"video.js": "^8.12.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ export default function AppWidget({
|
||||
return (
|
||||
<Tooltip.Floating label={t("error.notFound.tooltip")}>
|
||||
<Stack gap="xs" align="center" justify="center" h="100%" w="100%">
|
||||
<IconDeviceDesktopX size={width >= 96 ? "2rem" : "1.5rem"} />
|
||||
<IconDeviceDesktopX size={width >= 96 ? "2rem" : "1rem"} />
|
||||
{width >= 96 && (
|
||||
<Text ta="center" size="sm">
|
||||
{t("error.notFound.label")}
|
||||
|
||||
Reference in New Issue
Block a user