mirror of
https://github.com/biersoeckli/QuickStack.git
synced 2026-02-10 21:49:19 -06:00
Merge pull request #19 from biersoeckli/feature/multiuser-support
feat: multiuser support with users and roles
This commit is contained in:
50
prisma/migrations/20250226165121_migration/migration.sql
Normal file
50
prisma/migrations/20250226165121_migration/migration.sql
Normal file
@@ -0,0 +1,50 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "Role" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "RoleAppPermission" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"roleId" TEXT NOT NULL,
|
||||
"appId" TEXT NOT NULL,
|
||||
"permission" TEXT NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "RoleAppPermission_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES "Role" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "RoleAppPermission_appId_fkey" FOREIGN KEY ("appId") REFERENCES "App" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- RedefineTables
|
||||
PRAGMA defer_foreign_keys=ON;
|
||||
PRAGMA foreign_keys=OFF;
|
||||
CREATE TABLE "new_User" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"name" TEXT,
|
||||
"email" TEXT NOT NULL,
|
||||
"emailVerified" DATETIME,
|
||||
"password" TEXT NOT NULL,
|
||||
"twoFaSecret" TEXT,
|
||||
"twoFaEnabled" BOOLEAN NOT NULL DEFAULT false,
|
||||
"image" TEXT,
|
||||
"roleId" TEXT,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "User_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES "Role" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||
);
|
||||
INSERT INTO "new_User" ("createdAt", "email", "emailVerified", "id", "image", "name", "password", "twoFaEnabled", "twoFaSecret", "updatedAt") SELECT "createdAt", "email", "emailVerified", "id", "image", "name", "password", "twoFaEnabled", "twoFaSecret", "updatedAt" FROM "User";
|
||||
DROP TABLE "User";
|
||||
ALTER TABLE "new_User" RENAME TO "User";
|
||||
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||
PRAGMA foreign_keys=ON;
|
||||
PRAGMA defer_foreign_keys=OFF;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Role_name_key" ON "Role"("name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "RoleAppPermission_roleId_appId_key" ON "RoleAppPermission"("roleId", "appId");
|
||||
18
prisma/migrations/20250307150516_migration/migration.sql
Normal file
18
prisma/migrations/20250307150516_migration/migration.sql
Normal file
@@ -0,0 +1,18 @@
|
||||
-- RedefineTables
|
||||
PRAGMA defer_foreign_keys=ON;
|
||||
PRAGMA foreign_keys=OFF;
|
||||
CREATE TABLE "new_Role" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"canCreateNewApps" BOOLEAN NOT NULL DEFAULT false,
|
||||
"canAccessBackups" BOOLEAN NOT NULL DEFAULT false,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
INSERT INTO "new_Role" ("createdAt", "description", "id", "name", "updatedAt") SELECT "createdAt", "description", "id", "name", "updatedAt" FROM "Role";
|
||||
DROP TABLE "Role";
|
||||
ALTER TABLE "new_Role" RENAME TO "Role";
|
||||
CREATE UNIQUE INDEX "Role_name_key" ON "Role"("name");
|
||||
PRAGMA foreign_keys=ON;
|
||||
PRAGMA defer_foreign_keys=OFF;
|
||||
56
prisma/migrations/20250313084556_migration/migration.sql
Normal file
56
prisma/migrations/20250313084556_migration/migration.sql
Normal file
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `canCreateNewApps` on the `Role` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `roleId` on the `RoleAppPermission` table. All the data in the column will be lost.
|
||||
|
||||
*/
|
||||
-- CreateTable
|
||||
CREATE TABLE "RoleProjectPermission" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"roleId" TEXT NOT NULL,
|
||||
"projectId" TEXT NOT NULL,
|
||||
"createApps" BOOLEAN NOT NULL DEFAULT false,
|
||||
"deleteApps" BOOLEAN NOT NULL DEFAULT false,
|
||||
"writeApps" BOOLEAN NOT NULL DEFAULT false,
|
||||
"readApps" BOOLEAN NOT NULL DEFAULT false,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "RoleProjectPermission_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES "Role" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "RoleProjectPermission_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- RedefineTables
|
||||
PRAGMA defer_foreign_keys=ON;
|
||||
PRAGMA foreign_keys=OFF;
|
||||
CREATE TABLE "new_Role" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"canAccessBackups" BOOLEAN NOT NULL DEFAULT false,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
INSERT INTO "new_Role" ("canAccessBackups", "createdAt", "description", "id", "name", "updatedAt") SELECT "canAccessBackups", "createdAt", "description", "id", "name", "updatedAt" FROM "Role";
|
||||
DROP TABLE "Role";
|
||||
ALTER TABLE "new_Role" RENAME TO "Role";
|
||||
CREATE UNIQUE INDEX "Role_name_key" ON "Role"("name");
|
||||
CREATE TABLE "new_RoleAppPermission" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"appId" TEXT NOT NULL,
|
||||
"permission" TEXT NOT NULL,
|
||||
"roleProjectPermissionId" TEXT,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "RoleAppPermission_appId_fkey" FOREIGN KEY ("appId") REFERENCES "App" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "RoleAppPermission_roleProjectPermissionId_fkey" FOREIGN KEY ("roleProjectPermissionId") REFERENCES "RoleProjectPermission" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||
);
|
||||
INSERT INTO "new_RoleAppPermission" ("appId", "createdAt", "id", "permission", "updatedAt") SELECT "appId", "createdAt", "id", "permission", "updatedAt" FROM "RoleAppPermission";
|
||||
DROP TABLE "RoleAppPermission";
|
||||
ALTER TABLE "new_RoleAppPermission" RENAME TO "RoleAppPermission";
|
||||
CREATE UNIQUE INDEX "RoleAppPermission_roleProjectPermissionId_appId_key" ON "RoleAppPermission"("roleProjectPermissionId", "appId");
|
||||
PRAGMA foreign_keys=ON;
|
||||
PRAGMA defer_foreign_keys=OFF;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "RoleProjectPermission_roleId_projectId_key" ON "RoleProjectPermission"("roleId", "projectId");
|
||||
67
prisma/migrations/20250324084808_migration/migration.sql
Normal file
67
prisma/migrations/20250324084808_migration/migration.sql
Normal file
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the `Role` table. If the table is not empty, all the data it contains will be lost.
|
||||
|
||||
*/
|
||||
-- DropIndex
|
||||
DROP INDEX "Role_name_key";
|
||||
|
||||
-- DropTable
|
||||
PRAGMA foreign_keys=off;
|
||||
DROP TABLE "Role";
|
||||
PRAGMA foreign_keys=on;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "UserGroup" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"canAccessBackups" BOOLEAN NOT NULL DEFAULT false,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
|
||||
-- RedefineTables
|
||||
PRAGMA defer_foreign_keys=ON;
|
||||
PRAGMA foreign_keys=OFF;
|
||||
CREATE TABLE "new_RoleProjectPermission" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"roleId" TEXT NOT NULL,
|
||||
"projectId" TEXT NOT NULL,
|
||||
"createApps" BOOLEAN NOT NULL DEFAULT false,
|
||||
"deleteApps" BOOLEAN NOT NULL DEFAULT false,
|
||||
"writeApps" BOOLEAN NOT NULL DEFAULT false,
|
||||
"readApps" BOOLEAN NOT NULL DEFAULT false,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "RoleProjectPermission_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES "UserGroup" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "RoleProjectPermission_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
INSERT INTO "new_RoleProjectPermission" ("createApps", "createdAt", "deleteApps", "id", "projectId", "readApps", "roleId", "updatedAt", "writeApps") SELECT "createApps", "createdAt", "deleteApps", "id", "projectId", "readApps", "roleId", "updatedAt", "writeApps" FROM "RoleProjectPermission";
|
||||
DROP TABLE "RoleProjectPermission";
|
||||
ALTER TABLE "new_RoleProjectPermission" RENAME TO "RoleProjectPermission";
|
||||
CREATE UNIQUE INDEX "RoleProjectPermission_roleId_projectId_key" ON "RoleProjectPermission"("roleId", "projectId");
|
||||
CREATE TABLE "new_User" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"name" TEXT,
|
||||
"email" TEXT NOT NULL,
|
||||
"emailVerified" DATETIME,
|
||||
"password" TEXT NOT NULL,
|
||||
"twoFaSecret" TEXT,
|
||||
"twoFaEnabled" BOOLEAN NOT NULL DEFAULT false,
|
||||
"image" TEXT,
|
||||
"roleId" TEXT,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "User_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES "UserGroup" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||
);
|
||||
INSERT INTO "new_User" ("createdAt", "email", "emailVerified", "id", "image", "name", "password", "roleId", "twoFaEnabled", "twoFaSecret", "updatedAt") SELECT "createdAt", "email", "emailVerified", "id", "image", "name", "password", "roleId", "twoFaEnabled", "twoFaSecret", "updatedAt" FROM "User";
|
||||
DROP TABLE "User";
|
||||
ALTER TABLE "new_User" RENAME TO "User";
|
||||
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||
PRAGMA foreign_keys=ON;
|
||||
PRAGMA defer_foreign_keys=OFF;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "UserGroup_name_key" ON "UserGroup"("name");
|
||||
@@ -0,0 +1,2 @@
|
||||
DELETE FROM RoleProjectPermission;
|
||||
DELETE FROM RoleAppPermission;
|
||||
48
prisma/migrations/20250324085401_migration/migration.sql
Normal file
48
prisma/migrations/20250324085401_migration/migration.sql
Normal file
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `roleId` on the `RoleProjectPermission` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `roleId` on the `User` table. All the data in the column will be lost.
|
||||
- Added the required column `userGroupId` to the `RoleProjectPermission` table without a default value. This is not possible if the table is not empty.
|
||||
|
||||
*/
|
||||
-- RedefineTables
|
||||
PRAGMA defer_foreign_keys=ON;
|
||||
PRAGMA foreign_keys=OFF;
|
||||
CREATE TABLE "new_RoleProjectPermission" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"userGroupId" TEXT NOT NULL,
|
||||
"projectId" TEXT NOT NULL,
|
||||
"createApps" BOOLEAN NOT NULL DEFAULT false,
|
||||
"deleteApps" BOOLEAN NOT NULL DEFAULT false,
|
||||
"writeApps" BOOLEAN NOT NULL DEFAULT false,
|
||||
"readApps" BOOLEAN NOT NULL DEFAULT false,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "RoleProjectPermission_userGroupId_fkey" FOREIGN KEY ("userGroupId") REFERENCES "UserGroup" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "RoleProjectPermission_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
INSERT INTO "new_RoleProjectPermission" ("createApps", "createdAt", "deleteApps", "id", "projectId", "readApps", "updatedAt", "writeApps") SELECT "createApps", "createdAt", "deleteApps", "id", "projectId", "readApps", "updatedAt", "writeApps" FROM "RoleProjectPermission";
|
||||
DROP TABLE "RoleProjectPermission";
|
||||
ALTER TABLE "new_RoleProjectPermission" RENAME TO "RoleProjectPermission";
|
||||
CREATE UNIQUE INDEX "RoleProjectPermission_userGroupId_projectId_key" ON "RoleProjectPermission"("userGroupId", "projectId");
|
||||
CREATE TABLE "new_User" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"name" TEXT,
|
||||
"email" TEXT NOT NULL,
|
||||
"emailVerified" DATETIME,
|
||||
"password" TEXT NOT NULL,
|
||||
"twoFaSecret" TEXT,
|
||||
"twoFaEnabled" BOOLEAN NOT NULL DEFAULT false,
|
||||
"image" TEXT,
|
||||
"userGroupId" TEXT,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "User_userGroupId_fkey" FOREIGN KEY ("userGroupId") REFERENCES "UserGroup" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||
);
|
||||
INSERT INTO "new_User" ("createdAt", "email", "emailVerified", "id", "image", "name", "password", "twoFaEnabled", "twoFaSecret", "updatedAt") SELECT "createdAt", "email", "emailVerified", "id", "image", "name", "password", "twoFaEnabled", "twoFaSecret", "updatedAt" FROM "User";
|
||||
DROP TABLE "User";
|
||||
ALTER TABLE "new_User" RENAME TO "User";
|
||||
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||
PRAGMA foreign_keys=ON;
|
||||
PRAGMA defer_foreign_keys=OFF;
|
||||
@@ -17,7 +17,6 @@ generator zod {
|
||||
// relationModel = false // Do not generate related model
|
||||
|
||||
modelCase = "PascalCase" // (default) Output models using pascal case (ex. UserModel, PostModel)
|
||||
// modelCase = "camelCase" // Output models using camel case (ex. userModel, postModel)
|
||||
|
||||
modelSuffix = "Model" // (default) Suffix to apply to your prisma models when naming Zod schemas
|
||||
|
||||
@@ -70,14 +69,18 @@ model Session {
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
id String @id @default(cuid())
|
||||
name String?
|
||||
email String @unique
|
||||
email String @unique
|
||||
emailVerified DateTime?
|
||||
password String
|
||||
twoFaSecret String?
|
||||
twoFaEnabled Boolean @default(false)
|
||||
twoFaEnabled Boolean @default(false)
|
||||
image String?
|
||||
|
||||
userGroupId String?
|
||||
userGroup UserGroup? @relation(fields: [userGroupId], references: [id])
|
||||
|
||||
accounts Account[]
|
||||
sessions Session[]
|
||||
// Optional for WebAuthn support
|
||||
@@ -114,10 +117,57 @@ model Authenticator {
|
||||
|
||||
// *** FROM HERE CUSTOM CLASSES
|
||||
|
||||
model UserGroup {
|
||||
id String @id @default(uuid())
|
||||
name String @unique
|
||||
description String?
|
||||
canAccessBackups Boolean @default(false)
|
||||
|
||||
users User[]
|
||||
roleProjectPermissions RoleProjectPermission[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model RoleProjectPermission {
|
||||
id String @id @default(uuid())
|
||||
userGroup UserGroup @relation(fields: [userGroupId], references: [id], onDelete: Cascade)
|
||||
userGroupId String
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
projectId String
|
||||
createApps Boolean @default(false)
|
||||
deleteApps Boolean @default(false)
|
||||
writeApps Boolean @default(false)
|
||||
readApps Boolean @default(false)
|
||||
|
||||
roleAppPermissions RoleAppPermission[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([userGroupId, projectId])
|
||||
}
|
||||
|
||||
model RoleAppPermission {
|
||||
id String @id @default(uuid())
|
||||
app App @relation(fields: [appId], references: [id], onDelete: Cascade)
|
||||
appId String
|
||||
permission String // READ, READWRITE
|
||||
roleProjectPermission RoleProjectPermission? @relation(fields: [roleProjectPermissionId], references: [id])
|
||||
roleProjectPermissionId String?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([roleProjectPermissionId, appId])
|
||||
}
|
||||
|
||||
model Project {
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
apps App[]
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
apps App[]
|
||||
roleProjectPermissions RoleProjectPermission[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
@@ -151,11 +201,12 @@ model App {
|
||||
|
||||
webhookId String?
|
||||
|
||||
appDomains AppDomain[]
|
||||
appPorts AppPort[]
|
||||
appVolumes AppVolume[]
|
||||
appFileMounts AppFileMount[]
|
||||
appBasicAuths AppBasicAuth[]
|
||||
appDomains AppDomain[]
|
||||
appPorts AppPort[]
|
||||
appVolumes AppVolume[]
|
||||
appFileMounts AppFileMount[]
|
||||
appBasicAuths AppBasicAuth[]
|
||||
roleAppPermissions RoleAppPermission[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
72
src/__tests__/shared/utils/usergroup.utils.test.ts
Normal file
72
src/__tests__/shared/utils/usergroup.utils.test.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { UserGroupUtils } from "../../../shared/utils/role.utils";
|
||||
import { adminRoleName, RolePermissionEnum } from "@/shared/model/role-extended.model.ts";
|
||||
import { UserSession } from "@/shared/model/sim-session.model";
|
||||
|
||||
describe(UserGroupUtils.name, () => {
|
||||
let adminSession: UserSession;
|
||||
let regularSession: UserSession;
|
||||
|
||||
const projectId = "project-123";
|
||||
|
||||
beforeEach(() => {
|
||||
adminSession = {
|
||||
userGroup: {
|
||||
name: adminRoleName,
|
||||
},
|
||||
} as any;
|
||||
|
||||
// Regular user session without any project permissions by default
|
||||
regularSession = {
|
||||
userGroup: {
|
||||
name: "User",
|
||||
roleProjectPermissions: [],
|
||||
},
|
||||
} as any;
|
||||
});
|
||||
|
||||
test("should return true if user is admin", () => {
|
||||
const result = UserGroupUtils.sessionHasReadAccessToProject(adminSession, projectId);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test("should return false if non-admin user has no project permission", () => {
|
||||
const result = UserGroupUtils.sessionHasReadAccessToProject(regularSession, projectId);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
test("should return true if non-admin user has project permission with non-empty roleAppPermissions", () => {
|
||||
regularSession.userGroup!.roleProjectPermissions = [
|
||||
{
|
||||
projectId,
|
||||
roleAppPermissions: [{ appId: "app1", permission: RolePermissionEnum.READ }],
|
||||
readApps: false,
|
||||
},
|
||||
] as any;
|
||||
const result = UserGroupUtils.sessionHasReadAccessToProject(regularSession, projectId);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test("should return true if non-admin user has project permission with empty roleAppPermissions and readApps true", () => {
|
||||
regularSession.userGroup!.roleProjectPermissions = [
|
||||
{
|
||||
projectId,
|
||||
roleAppPermissions: [],
|
||||
readApps: true,
|
||||
},
|
||||
] as any;
|
||||
const result = UserGroupUtils.sessionHasReadAccessToProject(regularSession, projectId);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test("should return false if non-admin user has project permission with empty roleAppPermissions and readApps false", () => {
|
||||
regularSession.userGroup!.roleProjectPermissions = [
|
||||
{
|
||||
projectId,
|
||||
roleAppPermissions: [],
|
||||
readApps: false,
|
||||
},
|
||||
] as any;
|
||||
const result = UserGroupUtils.sessionHasReadAccessToProject(regularSession, projectId);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import k3s from "@/server/adapter/kubernetes-api.adapter";
|
||||
import appService from "@/server/services/app.service";
|
||||
import deploymentService from "@/server/services/deployment.service";
|
||||
import { getAuthUserSession, simpleRoute } from "@/server/utils/action-wrapper.utils";
|
||||
import { getAuthUserSession, isAuthorizedReadForApp, simpleRoute } from "@/server/utils/action-wrapper.utils";
|
||||
import { Informer, V1Pod } from "@kubernetes/client-node";
|
||||
import { z } from "zod";
|
||||
import * as k8s from '@kubernetes/client-node';
|
||||
@@ -18,6 +18,7 @@ export async function POST(request: Request) {
|
||||
const input = await request.json();
|
||||
const podInfo = zodInputModel.parse(input);
|
||||
let { appId } = podInfo;
|
||||
await isAuthorizedReadForApp(appId);
|
||||
|
||||
const app = await appService.getById(appId);
|
||||
const namespace = app.projectId;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { FsUtils } from "@/server/utils/fs.utils";
|
||||
import { PathUtils } from "@/server/utils/path.utils";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import fs from 'fs/promises';
|
||||
import { getAuthUserSession } from "@/server/utils/action-wrapper.utils";
|
||||
import { getAuthUserSession, isAuthorizedReadForApp } from "@/server/utils/action-wrapper.utils";
|
||||
import { ServiceException } from "@/shared/model/service.exception.model";
|
||||
import { z } from "zod";
|
||||
import { stringToDate } from "@/shared/utils/zod.utils";
|
||||
@@ -25,6 +25,8 @@ export async function GET(request: NextRequest) {
|
||||
const date = requestUrl.searchParams.get('date');
|
||||
const validatedData = zodInputModel.parse({ appId, date });
|
||||
|
||||
await isAuthorizedReadForApp(validatedData.appId);
|
||||
|
||||
const logsPath = PathUtils.appLogsFile(validatedData.appId, validatedData.date);
|
||||
if (!await FsUtils.fileExists(logsPath)) {
|
||||
throw new ServiceException(`Could not find logs for ${appId}.`);
|
||||
|
||||
@@ -9,6 +9,7 @@ import userService from "@/server/services/user.service";
|
||||
import { saveFormAction } from "@/server/utils/action-wrapper.utils";
|
||||
import ipAddressFinderAdapter from "@/server/adapter/ip-adress-finder.adapter";
|
||||
import traefikMeDomainStandaloneService from "@/server/services/standalone-services/traefik-me-domain-standalone.service";
|
||||
import userGroupService from "@/server/services/user-group.service";
|
||||
|
||||
|
||||
export const registerUser = async (prevState: any, inputData: RegisterFormInputSchema) =>
|
||||
@@ -17,7 +18,8 @@ export const registerUser = async (prevState: any, inputData: RegisterFormInputS
|
||||
if (allUsers.length !== 0) {
|
||||
throw new ServiceException("User registration is currently not possible");
|
||||
}
|
||||
await userService.registerUser(validatedData.email, validatedData.password);
|
||||
const adminRole = await userGroupService.getOrCreateAdminRole();
|
||||
await userService.registerUser(validatedData.email, validatedData.password, adminRole.id);
|
||||
await quickStackService.createOrUpdateCertIssuer(validatedData.email);
|
||||
|
||||
try {
|
||||
|
||||
@@ -4,7 +4,7 @@ import monitoringService from "@/server/services/monitoring.service";
|
||||
import clusterService from "@/server/services/node.service";
|
||||
import pvcService from "@/server/services/pvc.service";
|
||||
import backupService from "@/server/services/standalone-services/backup.service";
|
||||
import { getAuthUserSession, simpleAction } from "@/server/utils/action-wrapper.utils";
|
||||
import { getAuthUserSession, isAuthorizedForBackups, simpleAction } from "@/server/utils/action-wrapper.utils";
|
||||
import { AppMonitoringUsageModel } from "@/shared/model/app-monitoring-usage.model";
|
||||
import { AppVolumeMonitoringUsageModel } from "@/shared/model/app-volume-monitoring-usage.model";
|
||||
import { NodeResourceModel } from "@/shared/model/node-resource.model";
|
||||
@@ -13,7 +13,7 @@ import { z } from "zod";
|
||||
|
||||
export const downloadBackup = async (s3TargetId: string, s3Key: string) =>
|
||||
simpleAction(async () => {
|
||||
await getAuthUserSession();
|
||||
await isAuthorizedForBackups();
|
||||
|
||||
const validatetData = z.object({
|
||||
s3TargetId: z.string(),
|
||||
@@ -29,7 +29,7 @@ export const downloadBackup = async (s3TargetId: string, s3Key: string) =>
|
||||
|
||||
export const deleteBackup = async (s3TargetId: string, s3Key: string) =>
|
||||
simpleAction(async () => {
|
||||
await getAuthUserSession();
|
||||
await isAuthorizedForBackups();
|
||||
|
||||
const validatetData = z.object({
|
||||
s3TargetId: z.string(),
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
'use client'
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
import { SimpleDataTable } from "@/components/custom/simple-data-table";
|
||||
import { formatDateTime } from "@/frontend/utils/format.utils";
|
||||
import { List } from "lucide-react";
|
||||
import { BackupInfoModel } from "@/shared/model/backup-info.model";
|
||||
import { BackupDetailDialog } from "./backup-detail-overlay";
|
||||
|
||||
|
||||
|
||||
export default function BackupsTable({ data }: { data: BackupInfoModel[] }) {
|
||||
|
||||
|
||||
return <>
|
||||
<SimpleDataTable columns={[
|
||||
['projectId', 'Project ID', false],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use server'
|
||||
|
||||
import { getAuthUserSession } from "@/server/utils/action-wrapper.utils";
|
||||
import { getAuthUserSession, isAuthorizedForBackups } from "@/server/utils/action-wrapper.utils";
|
||||
import PageTitle from "@/components/custom/page-title";
|
||||
import backupService from "@/server/services/standalone-services/backup.service";
|
||||
import BackupsTable from "./backups-table";
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
|
||||
export default async function BackupsPage() {
|
||||
|
||||
await getAuthUserSession();
|
||||
await isAuthorizedForBackups();
|
||||
const {
|
||||
backupInfoModels,
|
||||
backupsVolumesWithoutActualBackups
|
||||
|
||||
43
src/app/global-error.tsx
Normal file
43
src/app/global-error.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
'use client' // Error boundaries must be Client Components
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { cn } from "@/frontend/utils/utils";
|
||||
import { AlertCircle } from "lucide-react"
|
||||
import { Inter } from "next/font/google";
|
||||
|
||||
const inter = Inter({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-sans",
|
||||
});
|
||||
|
||||
export default function GlobalError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string }
|
||||
reset: () => void
|
||||
}) {
|
||||
return (
|
||||
<html >
|
||||
<body className={cn(
|
||||
"min-h-screen bg-background font-sans antialiased",
|
||||
inter.variable
|
||||
)}>
|
||||
<div className="h-screen w-fuäll flex flex-col items-center justify-center p-4 space-y-4 bg-background text-foreground">
|
||||
<div className="flex flex-col items-center justify-center space-y-2 text-center max-w-md">
|
||||
<div className="rounded-full bg-destructive/10 p-3">
|
||||
<AlertCircle className="h-8 w-8 text-destructive" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Something went wrong!</h2>
|
||||
<p className="text-muted-foreground mt-4">
|
||||
An unexpected error occurred. Please check if your authorized for this action and try again.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-6">
|
||||
Digest: {error.digest}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { cookies } from "next/headers";
|
||||
import { BreadcrumbsGenerator } from "../components/custom/breadcrumbs-generator";
|
||||
import { getUserSession } from "@/server/utils/action-wrapper.utils";
|
||||
import { InputDialog } from "@/components/custom/input-dialog";
|
||||
import userGroupService from "@/server/services/user-group.service";
|
||||
|
||||
const inter = Inter({
|
||||
subsets: ["latin"],
|
||||
@@ -40,6 +41,9 @@ export default async function RootLayout({
|
||||
const session = await getUserSession();
|
||||
const userIsLoggedIn = !!session;
|
||||
|
||||
// todo remove in future versions and handle migrations in an other way
|
||||
await userGroupService.createDefaultRolesIfNotExists();
|
||||
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className={cn(
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import monitoringService from "@/server/services/monitoring.service";
|
||||
import clusterService from "@/server/services/node.service";
|
||||
import { getAuthUserSession, simpleAction } from "@/server/utils/action-wrapper.utils";
|
||||
import { UserGroupUtils } from "@/shared/utils/role.utils";
|
||||
import { AppMonitoringUsageModel } from "@/shared/model/app-monitoring-usage.model";
|
||||
import { AppVolumeMonitoringUsageModel } from "@/shared/model/app-volume-monitoring-usage.model";
|
||||
import { NodeResourceModel } from "@/shared/model/node-resource.model";
|
||||
@@ -16,12 +17,16 @@ export const getNodeResourceUsage = async () =>
|
||||
|
||||
export const getVolumeMonitoringUsage = async () =>
|
||||
simpleAction(async () => {
|
||||
await getAuthUserSession();
|
||||
return await monitoringService.getAllAppVolumesUsage();
|
||||
const session = await getAuthUserSession();
|
||||
let volumesUsage = await monitoringService.getAllAppVolumesUsage();
|
||||
volumesUsage = volumesUsage?.filter((volume) => UserGroupUtils.sessionHasReadAccessForApp(session, volume.appId));
|
||||
return volumesUsage;
|
||||
}) as Promise<ServerActionResult<unknown, AppVolumeMonitoringUsageModel[]>>;
|
||||
|
||||
export const getMonitoringForAllApps = async () =>
|
||||
simpleAction(async () => {
|
||||
await getAuthUserSession();
|
||||
return await monitoringService.getMonitoringForAllApps();
|
||||
const session = await getAuthUserSession();
|
||||
let updatedNodeRessources = await monitoringService.getMonitoringForAllApps();
|
||||
updatedNodeRessources = updatedNodeRessources?.filter((app) => UserGroupUtils.sessionHasReadAccessForApp(session, app.appId));
|
||||
return updatedNodeRessources;
|
||||
}) as Promise<ServerActionResult<unknown, AppMonitoringUsageModel[]>>;
|
||||
@@ -10,10 +10,11 @@ import monitoringService from "@/server/services/monitoring.service";
|
||||
import AppRessourceMonitoring from "./app-monitoring";
|
||||
import AppVolumeMonitoring from "./app-volumes-monitoring";
|
||||
import { AppMonitoringUsageModel } from "@/shared/model/app-monitoring-usage.model";
|
||||
import { UserGroupUtils } from "@/shared/utils/role.utils";
|
||||
|
||||
export default async function ResourceNodesInfoPage() {
|
||||
|
||||
await getAuthUserSession();
|
||||
const session = await getAuthUserSession();
|
||||
let resourcesNode: NodeResourceModel[] | undefined;
|
||||
let volumesUsage: AppVolumeMonitoringUsageModel[] | undefined;
|
||||
let updatedNodeRessources: AppMonitoringUsageModel[] | undefined;
|
||||
@@ -27,6 +28,10 @@ export default async function ResourceNodesInfoPage() {
|
||||
// do nothing --> if an error occurs, the ResourceNodes will show a loading spinner and error message
|
||||
}
|
||||
|
||||
// filter by role
|
||||
volumesUsage = volumesUsage?.filter((volume) => UserGroupUtils.sessionHasReadAccessForApp(session, volume.appId));
|
||||
updatedNodeRessources = updatedNodeRessources?.filter((app) => UserGroupUtils.sessionHasReadAccessForApp(session, app.appId));
|
||||
|
||||
return (
|
||||
<div className="flex-1 space-y-4 pt-6">
|
||||
<PageTitle
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { SuccessActionResult } from "@/shared/model/server-action-error-return.model";
|
||||
import appService from "@/server/services/app.service";
|
||||
import { getAuthUserSession, saveFormAction, simpleAction } from "@/server/utils/action-wrapper.utils";
|
||||
import { getAuthUserSession, isAuthorizedWriteForApp, saveFormAction, simpleAction } from "@/server/utils/action-wrapper.utils";
|
||||
import { z } from "zod";
|
||||
import appTemplateService from "@/server/services/app-template.service";
|
||||
import { AppTemplateModel, appTemplateZodModel } from "@/shared/model/app-template.model";
|
||||
@@ -11,6 +11,7 @@ import dbGateService from "@/server/services/db-tool-services/dbgate.service";
|
||||
import fileBrowserService from "@/server/services/file-browser-service";
|
||||
import phpMyAdminService from "@/server/services/db-tool-services/phpmyadmin.service";
|
||||
import pgAdminService from "@/server/services/db-tool-services/pgadmin.service";
|
||||
import { UserGroupUtils } from "@/shared/utils/role.utils";
|
||||
|
||||
const createAppSchema = z.object({
|
||||
appName: z.string().min(1)
|
||||
@@ -18,7 +19,10 @@ const createAppSchema = z.object({
|
||||
|
||||
export const createApp = async (appName: string, projectId: string, appId?: string) =>
|
||||
saveFormAction({ appName }, createAppSchema, async (validatedData) => {
|
||||
await getAuthUserSession();
|
||||
const session = await getAuthUserSession();
|
||||
if (!UserGroupUtils.sessionCanCreateNewAppsForProject(session, projectId)) {
|
||||
throw new ServiceException("You are not allowed to create new apps.");
|
||||
}
|
||||
|
||||
const returnData = await appService.save({
|
||||
id: appId ?? undefined,
|
||||
@@ -31,7 +35,10 @@ export const createApp = async (appName: string, projectId: string, appId?: stri
|
||||
|
||||
export const createAppFromTemplate = async (prevState: any, inputData: AppTemplateModel, projectId: string) =>
|
||||
saveFormAction(inputData, appTemplateZodModel, async (validatedData) => {
|
||||
await getAuthUserSession();
|
||||
const session = await getAuthUserSession();
|
||||
if (!UserGroupUtils.sessionCanCreateNewAppsForProject(session, projectId)) {
|
||||
throw new ServiceException("You are not allowed to create new apps.");
|
||||
}
|
||||
if (validatedData.templates.some(x => x.inputSettings.some(y => !y.randomGeneratedIfEmpty && !y.value))) {
|
||||
throw new ServiceException('Please fill out all required fields.');
|
||||
}
|
||||
@@ -41,8 +48,11 @@ export const createAppFromTemplate = async (prevState: any, inputData: AppTempla
|
||||
|
||||
export const deleteApp = async (appId: string) =>
|
||||
simpleAction(async () => {
|
||||
await getAuthUserSession();
|
||||
const session = await getAuthUserSession();
|
||||
const app = await appService.getExtendedById(appId);
|
||||
if (!UserGroupUtils.sessionCanDeleteAppsForProject(session, app.projectId)) {
|
||||
throw new ServiceException("You are not allowed to delete apps in this project.");
|
||||
}
|
||||
// First delete external services wich might be running
|
||||
await dbGateService.deleteToolForAppIfExists(appId);
|
||||
await phpMyAdminService.deleteToolForAppIfExists(appId);
|
||||
|
||||
@@ -13,10 +13,19 @@ import { deleteApp } from "./actions";
|
||||
import { useBreadcrumbs, useConfirmDialog } from "@/frontend/states/zustand.states";
|
||||
import { useEffect } from "react";
|
||||
import { EditAppDialog } from "./edit-app-dialog";
|
||||
import { UserSession } from "@/shared/model/sim-session.model";
|
||||
import { UserGroupUtils } from "@/shared/utils/role.utils";
|
||||
|
||||
|
||||
|
||||
export default function AppTable({ app, projectId }: { app: App[], projectId: string }) {
|
||||
export default function AppTable({
|
||||
app,
|
||||
projectId,
|
||||
session
|
||||
}: {
|
||||
app: App[],
|
||||
projectId: string,
|
||||
session: UserSession
|
||||
}) {
|
||||
|
||||
const { openConfirmDialog: openDialog } = useConfirmDialog();
|
||||
|
||||
@@ -54,18 +63,19 @@ export default function AppTable({ app, projectId }: { app: App[], projectId: st
|
||||
</DropdownMenuItem>
|
||||
</Link>
|
||||
<DropdownMenuSeparator />
|
||||
<EditAppDialog projectId={projectId} existingItem={item}>
|
||||
<DropdownMenuItem>
|
||||
<Edit2 /> <span>Edit App Name</span>
|
||||
</DropdownMenuItem>
|
||||
</EditAppDialog>
|
||||
<DropdownMenuItem className="text-red-500"
|
||||
{UserGroupUtils.sessionCanCreateNewAppsForProject(session, projectId) &&
|
||||
<EditAppDialog projectId={projectId} existingItem={item}>
|
||||
<DropdownMenuItem>
|
||||
<Edit2 /> <span>Edit App Name</span>
|
||||
</DropdownMenuItem>
|
||||
</EditAppDialog>}
|
||||
{UserGroupUtils.sessionCanDeleteAppsForProject(session, projectId) && <DropdownMenuItem className="text-red-500"
|
||||
onClick={() => openDialog({
|
||||
title: "Delete App",
|
||||
description: "Are you sure you want to delete this app? All data will be lost and this action cannot be undone.",
|
||||
}).then((result) => result ? Toast.fromAction(() => deleteApp(item.id)) : undefined)}>
|
||||
<Trash /> <span >Delete App</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuItem>}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,7 @@ import appService from "@/server/services/app.service";
|
||||
import PageTitle from "@/components/custom/page-title";
|
||||
import ProjectBreadcrumbs from "./project-breadcrumbs";
|
||||
import CreateProjectActions from "./create-project-actions";
|
||||
import { UserGroupUtils } from "@/shared/utils/role.utils";
|
||||
|
||||
export default async function AppsPage({
|
||||
searchParams,
|
||||
@@ -16,7 +17,7 @@ export default async function AppsPage({
|
||||
searchParams?: { [key: string]: string | undefined };
|
||||
params: { projectId: string }
|
||||
}) {
|
||||
await getAuthUserSession();
|
||||
const session = await getAuthUserSession();
|
||||
|
||||
const projectId = params?.projectId;
|
||||
if (!projectId) {
|
||||
@@ -24,14 +25,18 @@ export default async function AppsPage({
|
||||
}
|
||||
const project = await projectService.getById(projectId);
|
||||
const data = await appService.getAllAppsByProjectID(projectId);
|
||||
const relevantApps = data.filter((app) =>
|
||||
UserGroupUtils.sessionHasReadAccessForApp(session, app.id));
|
||||
|
||||
return (
|
||||
<div className="flex-1 space-y-4 pt-6">
|
||||
<PageTitle
|
||||
title="Apps"
|
||||
subtitle={`All Apps for Project "${project.name}"`}>
|
||||
<CreateProjectActions projectId={projectId} />
|
||||
{UserGroupUtils.sessionCanCreateNewAppsForProject(session, params.projectId) &&
|
||||
<CreateProjectActions projectId={projectId} />}
|
||||
</PageTitle>
|
||||
<AppTable app={data} projectId={project.id} />
|
||||
<AppTable session={session} app={relevantApps} projectId={project.id} />
|
||||
<ProjectBreadcrumbs project={project} />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -3,20 +3,20 @@
|
||||
import { SuccessActionResult } from "@/shared/model/server-action-error-return.model";
|
||||
import appService from "@/server/services/app.service";
|
||||
import deploymentService from "@/server/services/deployment.service";
|
||||
import { getAuthUserSession, simpleAction } from "@/server/utils/action-wrapper.utils";
|
||||
import { isAuthorizedReadForApp, isAuthorizedWriteForApp, simpleAction } from "@/server/utils/action-wrapper.utils";
|
||||
import eventService from "@/server/services/event.service";
|
||||
|
||||
|
||||
export const deploy = async (appId: string, forceBuild = false) =>
|
||||
simpleAction(async () => {
|
||||
await getAuthUserSession();
|
||||
await isAuthorizedWriteForApp(appId);
|
||||
await appService.buildAndDeploy(appId, forceBuild);
|
||||
return new SuccessActionResult(undefined, 'Successfully started deployment.');
|
||||
});
|
||||
|
||||
export const stopApp = async (appId: string) =>
|
||||
simpleAction(async () => {
|
||||
await getAuthUserSession();
|
||||
await isAuthorizedWriteForApp(appId);
|
||||
const app = await appService.getExtendedById(appId);
|
||||
await deploymentService.setReplicasForDeployment(app.projectId, app.id, 0);
|
||||
return new SuccessActionResult(undefined, 'Successfully stopped app.');
|
||||
@@ -24,7 +24,7 @@ export const stopApp = async (appId: string) =>
|
||||
|
||||
export const startApp = async (appId: string) =>
|
||||
simpleAction(async () => {
|
||||
await getAuthUserSession();
|
||||
await isAuthorizedWriteForApp(appId);
|
||||
const app = await appService.getExtendedById(appId);
|
||||
await deploymentService.setReplicasForDeployment(app.projectId, app.id, app.replicas);
|
||||
return new SuccessActionResult(undefined, 'Successfully started app.');
|
||||
@@ -32,7 +32,7 @@ export const startApp = async (appId: string) =>
|
||||
|
||||
export const getLatestAppEvents = async (appId: string) =>
|
||||
simpleAction(async () => {
|
||||
await getAuthUserSession();
|
||||
await isAuthorizedReadForApp(appId);
|
||||
const app = await appService.getById(appId);
|
||||
return await eventService.getEventsForApp(app.projectId, app.id);
|
||||
});
|
||||
|
||||
@@ -1,24 +1,14 @@
|
||||
'use server'
|
||||
|
||||
import { appVolumeEditZodModel } from "@/shared/model/volume-edit.model";
|
||||
import { ServerActionResult, SuccessActionResult } from "@/shared/model/server-action-error-return.model";
|
||||
import { SuccessActionResult } from "@/shared/model/server-action-error-return.model";
|
||||
import appService from "@/server/services/app.service";
|
||||
import { getAuthUserSession, saveFormAction, simpleAction } from "@/server/utils/action-wrapper.utils";
|
||||
import { z } from "zod";
|
||||
import { ServiceException } from "@/shared/model/service.exception.model";
|
||||
import pvcService from "@/server/services/pvc.service";
|
||||
import { fileMountEditZodModel } from "@/shared/model/file-mount-edit.model";
|
||||
import { VolumeBackupEditModel, volumeBackupEditZodModel } from "@/shared/model/backup-volume-edit.model";
|
||||
import volumeBackupService from "@/server/services/volume-backup.service";
|
||||
import backupService from "@/server/services/standalone-services/backup.service";
|
||||
import { volumeUploadZodModel } from "@/shared/model/volume-upload.model";
|
||||
import restoreService from "@/server/services/restore.service";
|
||||
import { isAuthorizedWriteForApp, saveFormAction, simpleAction } from "@/server/utils/action-wrapper.utils";
|
||||
import { BasicAuthEditModel, basicAuthEditZodModel } from "@/shared/model/basic-auth-edit.model";
|
||||
|
||||
|
||||
export const saveBasicAuth = async (prevState: any, inputData: BasicAuthEditModel) =>
|
||||
saveFormAction(inputData, basicAuthEditZodModel, async (validatedData) => {
|
||||
await getAuthUserSession();
|
||||
await isAuthorizedWriteForApp(validatedData.appId);
|
||||
|
||||
await appService.saveBasicAuth({
|
||||
...validatedData,
|
||||
@@ -30,7 +20,7 @@ export const saveBasicAuth = async (prevState: any, inputData: BasicAuthEditMode
|
||||
|
||||
export const deleteBasicAuth = async (basicAuthId: string) =>
|
||||
simpleAction(async () => {
|
||||
await getAuthUserSession();
|
||||
await isAuthorizedWriteForApp(await appService.getBasicAuthById(basicAuthId).then(b => b.appId));
|
||||
await appService.deleteBasicAuthById(basicAuthId);
|
||||
return new SuccessActionResult(undefined, 'Successfully deleted item');
|
||||
});
|
||||
|
||||
@@ -43,7 +43,6 @@ export default function BasicAuthEditDialog({
|
||||
|
||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||
|
||||
|
||||
const form = useForm<BasicAuthEditModel>({
|
||||
resolver: zodResolver(basicAuthEditZodModel.merge(z.object({
|
||||
appId: z.string().nullish()
|
||||
|
||||
@@ -13,8 +13,9 @@ import BasicAuthEditDialog from "./basic-auth-edit-dialog";
|
||||
import { deleteBasicAuth } from "./actions";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
|
||||
export default function BasicAuth({ app }: {
|
||||
app: AppExtendedModel
|
||||
export default function BasicAuth({ app, readonly }: {
|
||||
app: AppExtendedModel;
|
||||
readonly: boolean;
|
||||
}) {
|
||||
|
||||
const { openConfirmDialog: openDialog } = useConfirmDialog();
|
||||
@@ -64,24 +65,24 @@ export default function BasicAuth({ app }: {
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium flex gap-2">
|
||||
{!readonly && <TableCell className="font-medium flex gap-2">
|
||||
<BasicAuthEditDialog app={app} basicAuth={basicAuth}>
|
||||
<Button variant="ghost"><EditIcon /></Button>
|
||||
</BasicAuthEditDialog>
|
||||
<Button variant="ghost" onClick={() => asyncDelete(basicAuth.id)}>
|
||||
<TrashIcon />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableCell>}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
{!readonly && <CardFooter>
|
||||
<FileMountEditDialog app={app}>
|
||||
<Button>Add Auth Credential</Button>
|
||||
</FileMountEditDialog>
|
||||
</CardFooter>
|
||||
</CardFooter>}
|
||||
</Card >
|
||||
</>;
|
||||
}
|
||||
@@ -7,24 +7,29 @@ import { AppExtendedModel } from "@/shared/model/app-extended.model";
|
||||
import { Toast } from "@/frontend/utils/toast.utils";
|
||||
import AppStatus from "./app-status";
|
||||
import { ExternalLink, Hammer, Pause, Play, Rocket } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { AppEventsDialog } from "./app-events-dialog";
|
||||
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
|
||||
import { UserSession } from "@/shared/model/sim-session.model";
|
||||
import { UserGroupUtils } from "@/shared/utils/role.utils";
|
||||
|
||||
export default function AppActionButtons({
|
||||
app
|
||||
app,
|
||||
session
|
||||
}: {
|
||||
app: AppExtendedModel;
|
||||
session: UserSession;
|
||||
}) {
|
||||
const hasWriteAccess = UserGroupUtils.sessionHasWriteAccessForApp(session, app.id);
|
||||
return <Card>
|
||||
<CardContent className="p-4 ">
|
||||
<ScrollArea>
|
||||
<div className="flex gap-4">
|
||||
<div className="self-center"><AppEventsDialog app={app}><AppStatus appId={app.id} /></AppEventsDialog></div>
|
||||
<Button onClick={() => Toast.fromAction(() => deploy(app.id))}><Rocket /> Deploy</Button>
|
||||
<Button onClick={() => Toast.fromAction(() => deploy(app.id, true))} variant="secondary"><Hammer /> Rebuild</Button>
|
||||
<Button onClick={() => Toast.fromAction(() => startApp(app.id))} variant="secondary"><Play />Start</Button>
|
||||
<Button onClick={() => Toast.fromAction(() => stopApp(app.id))} variant="secondary"><Pause /> Stop</Button>
|
||||
{hasWriteAccess && <><Button onClick={() => Toast.fromAction(() => deploy(app.id))}><Rocket /> Deploy</Button>
|
||||
<Button onClick={() => Toast.fromAction(() => deploy(app.id, true))} variant="secondary"><Hammer /> Rebuild</Button>
|
||||
<Button onClick={() => Toast.fromAction(() => startApp(app.id))} variant="secondary"><Play />Start</Button>
|
||||
<Button onClick={() => Toast.fromAction(() => stopApp(app.id))} variant="secondary"><Pause /> Stop</Button>
|
||||
</>}
|
||||
{app.appDomains.length > 0 && <Button onClick={() => {
|
||||
const domain = app.appDomains[0];
|
||||
const protocol = domain.useSsl ? 'https' : 'http';
|
||||
|
||||
@@ -21,29 +21,31 @@ import { VolumeBackupExtendedModel } from "@/shared/model/volume-backup-extended
|
||||
import BasicAuth from "./advanced/basic-auth";
|
||||
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
|
||||
import DbToolsCard from "./credentials/db-tools";
|
||||
import { RolePermissionEnum } from "@/shared/model/role-extended.model.ts";
|
||||
|
||||
export default function AppTabs({
|
||||
app,
|
||||
role,
|
||||
tabName,
|
||||
s3Targets,
|
||||
volumeBackups
|
||||
}: {
|
||||
app: AppExtendedModel;
|
||||
role: RolePermissionEnum;
|
||||
tabName: string;
|
||||
s3Targets: S3Target[],
|
||||
volumeBackups: VolumeBackupExtendedModel[]
|
||||
}) {
|
||||
const router = useRouter();
|
||||
|
||||
const readonly = role !== RolePermissionEnum.READWRITE;
|
||||
const openTab = (tabName: string) => {
|
||||
router.push(`/project/app/${app.id}?tabName=${tabName}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<Tabs defaultValue="general" value={tabName} onValueChange={(newTab) => openTab(newTab)} className="space-y-4">
|
||||
<ScrollArea >
|
||||
<ScrollArea>
|
||||
<TabsList>
|
||||
|
||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||
{app.appType !== 'APP' && <TabsTrigger value="credentials">Credentials</TabsTrigger>}
|
||||
<TabsTrigger value="general">General</TabsTrigger>
|
||||
@@ -56,35 +58,36 @@ export default function AppTabs({
|
||||
</ScrollArea>
|
||||
<TabsContent value="overview" className="grid grid-cols-1 3xl:grid-cols-2 gap-4">
|
||||
<MonitoringTab app={app} />
|
||||
<Logs app={app} />
|
||||
<BuildsTab app={app} />
|
||||
<WebhookDeploymentInfo app={app} />
|
||||
<Logs role={role} app={app} />
|
||||
<BuildsTab role={role} app={app} />
|
||||
<WebhookDeploymentInfo role={role} app={app} />
|
||||
</TabsContent>
|
||||
{app.appType !== 'APP' && <TabsContent value="credentials" className="space-y-4">
|
||||
<DbToolsCard app={app} />
|
||||
{role === RolePermissionEnum.READWRITE && <DbToolsCard app={app} />}
|
||||
<DbCredentials app={app} />
|
||||
</TabsContent>}
|
||||
<TabsContent value="general" className="space-y-4">
|
||||
<GeneralAppSource app={app} />
|
||||
<GeneralAppRateLimits app={app} />
|
||||
<GeneralAppSource readonly={readonly} app={app} />
|
||||
<GeneralAppRateLimits readonly={readonly} app={app} />
|
||||
</TabsContent>
|
||||
<TabsContent value="environment" className="space-y-4">
|
||||
<EnvEdit app={app} />
|
||||
<EnvEdit readonly={readonly} app={app} />
|
||||
</TabsContent>
|
||||
<TabsContent value="domains" className="space-y-4">
|
||||
<DomainsList app={app} />
|
||||
<InternalHostnames app={app} />
|
||||
<DomainsList readonly={readonly} app={app} />
|
||||
<InternalHostnames readonly={readonly} app={app} />
|
||||
</TabsContent>
|
||||
<TabsContent value="storage" className="space-y-4">
|
||||
<StorageList app={app} />
|
||||
<FileMount app={app} />
|
||||
<StorageList readonly={readonly} app={app} />
|
||||
<FileMount readonly={readonly} app={app} />
|
||||
<VolumeBackupList
|
||||
readonly={readonly}
|
||||
app={app}
|
||||
s3Targets={s3Targets}
|
||||
volumeBackups={volumeBackups} />
|
||||
</TabsContent>
|
||||
<TabsContent value="advanced" className="space-y-4">
|
||||
<BasicAuth app={app} />
|
||||
<BasicAuth readonly={readonly} app={app} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)
|
||||
|
||||
@@ -4,7 +4,7 @@ import appService from "@/server/services/app.service";
|
||||
import dbGateService from "@/server/services/db-tool-services/dbgate.service";
|
||||
import pgAdminService from "@/server/services/db-tool-services/pgadmin.service";
|
||||
import phpMyAdminService from "@/server/services/db-tool-services/phpmyadmin.service";
|
||||
import { getAuthUserSession, simpleAction } from "@/server/utils/action-wrapper.utils";
|
||||
import { isAuthorizedReadForApp, isAuthorizedWriteForApp, simpleAction } from "@/server/utils/action-wrapper.utils";
|
||||
import { AppTemplateUtils } from "@/server/utils/app-template.utils";
|
||||
import { DatabaseTemplateInfoModel } from "@/shared/model/database-template-info.model";
|
||||
import { ServerActionResult, SuccessActionResult } from "@/shared/model/server-action-error-return.model";
|
||||
@@ -20,7 +20,7 @@ const dbToolClasses = new Map([
|
||||
|
||||
export const getDatabaseCredentials = async (appId: string) =>
|
||||
simpleAction(async () => {
|
||||
await getAuthUserSession();
|
||||
await isAuthorizedReadForApp(appId);
|
||||
const app = await appService.getExtendedById(appId);
|
||||
const credentials = AppTemplateUtils.getDatabaseModelFromApp(app);
|
||||
return new SuccessActionResult(credentials);
|
||||
@@ -28,7 +28,7 @@ export const getDatabaseCredentials = async (appId: string) =>
|
||||
|
||||
export const getIsDbToolActive = async (appId: string, dbTool: DbToolIds) =>
|
||||
simpleAction(async () => {
|
||||
await getAuthUserSession();
|
||||
await isAuthorizedWriteForApp(appId);
|
||||
if (!dbToolClasses.has(dbTool)) {
|
||||
throw new ServiceException('Unknown db tool');
|
||||
}
|
||||
@@ -38,7 +38,7 @@ export const getIsDbToolActive = async (appId: string, dbTool: DbToolIds) =>
|
||||
|
||||
export const deployDbTool = async (appId: string, dbTool: DbToolIds) =>
|
||||
simpleAction(async () => {
|
||||
await getAuthUserSession();
|
||||
await isAuthorizedWriteForApp(appId);
|
||||
|
||||
const currentDbTool = dbToolClasses.get(dbTool);
|
||||
if (!currentDbTool) {
|
||||
@@ -51,7 +51,7 @@ export const deployDbTool = async (appId: string, dbTool: DbToolIds) =>
|
||||
|
||||
export const getLoginCredentialsForRunningDbTool = async (appId: string, dbTool: DbToolIds) =>
|
||||
simpleAction(async () => {
|
||||
await getAuthUserSession();
|
||||
await isAuthorizedWriteForApp(appId);
|
||||
|
||||
const currentDbTool = dbToolClasses.get(dbTool);
|
||||
if (!currentDbTool) {
|
||||
@@ -63,7 +63,7 @@ export const getLoginCredentialsForRunningDbTool = async (appId: string, dbTool:
|
||||
|
||||
export const deleteDbToolDeploymentForAppIfExists = async (appId: string, dbTool: DbToolIds) =>
|
||||
simpleAction(async () => {
|
||||
await getAuthUserSession();
|
||||
await isAuthorizedWriteForApp(appId);
|
||||
|
||||
const currentDbTool = dbToolClasses.get(dbTool);
|
||||
if (!currentDbTool) {
|
||||
@@ -76,7 +76,7 @@ export const deleteDbToolDeploymentForAppIfExists = async (appId: string, dbTool
|
||||
|
||||
export const downloadDbGateFilesForApp = async (appId: string) =>
|
||||
simpleAction(async () => {
|
||||
await getAuthUserSession();
|
||||
await isAuthorizedWriteForApp(appId);
|
||||
const url = await dbGateService.downloadDbGateFilesForApp(appId);
|
||||
return new SuccessActionResult(url);
|
||||
}) as Promise<ServerActionResult<unknown, string>>;
|
||||
@@ -4,7 +4,7 @@ import { AppPortModel, appPortZodModel } from "@/shared/model/default-port.model
|
||||
import { appDomainEditZodModel } from "@/shared/model/domain-edit.model";
|
||||
import { SuccessActionResult } from "@/shared/model/server-action-error-return.model";
|
||||
import appService from "@/server/services/app.service";
|
||||
import { getAuthUserSession, saveFormAction, simpleAction } from "@/server/utils/action-wrapper.utils";
|
||||
import { getAuthUserSession, isAuthorizedWriteForApp, saveFormAction, simpleAction } from "@/server/utils/action-wrapper.utils";
|
||||
import { z } from "zod";
|
||||
import { TraefikMeUtils } from "@/shared/utils/traefik-me.utils";
|
||||
import { ServiceException } from "@/shared/model/service.exception.model";
|
||||
@@ -16,7 +16,7 @@ const actionAppDomainEditZodModel = appDomainEditZodModel.merge(z.object({
|
||||
|
||||
export const saveDomain = async (prevState: any, inputData: z.infer<typeof actionAppDomainEditZodModel>) =>
|
||||
saveFormAction(inputData, actionAppDomainEditZodModel, async (validatedData) => {
|
||||
await getAuthUserSession();
|
||||
await isAuthorizedWriteForApp(validatedData.appId);
|
||||
|
||||
if (validatedData.hostname.includes('://')) {
|
||||
const url = new URL(validatedData.hostname);
|
||||
@@ -37,14 +37,14 @@ export const saveDomain = async (prevState: any, inputData: z.infer<typeof actio
|
||||
|
||||
export const deleteDomain = async (domainId: string) =>
|
||||
simpleAction(async () => {
|
||||
await getAuthUserSession();
|
||||
await isAuthorizedWriteForApp(await appService.getDomainById(domainId).then(d => d.appId));
|
||||
await appService.deleteDomainById(domainId);
|
||||
return new SuccessActionResult(undefined, 'Successfully deleted domain');
|
||||
});
|
||||
|
||||
export const savePort = async (prevState: any, inputData: AppPortModel, appId: string, portId?: string) =>
|
||||
saveFormAction(inputData, appPortZodModel, async (validatedData) => {
|
||||
await getAuthUserSession();
|
||||
await isAuthorizedWriteForApp(appId);
|
||||
await appService.savePort({
|
||||
...validatedData,
|
||||
id: portId ?? undefined,
|
||||
@@ -54,7 +54,7 @@ export const savePort = async (prevState: any, inputData: AppPortModel, appId: s
|
||||
|
||||
export const deletePort = async (portId: string) =>
|
||||
simpleAction(async () => {
|
||||
await getAuthUserSession();
|
||||
await isAuthorizedWriteForApp(await appService.getPortById(portId).then(p => p.appId));
|
||||
await appService.deletePortById(portId);
|
||||
return new SuccessActionResult(undefined, 'Successfully deleted port');
|
||||
});
|
||||
@@ -13,8 +13,9 @@ import { OpenInNewWindowIcon } from "@radix-ui/react-icons";
|
||||
import { useConfirmDialog } from "@/frontend/states/zustand.states";
|
||||
|
||||
|
||||
export default function DomainsList({ app }: {
|
||||
app: AppExtendedModel
|
||||
export default function DomainsList({ app, readonly }: {
|
||||
app: AppExtendedModel;
|
||||
readonly: boolean;
|
||||
}) {
|
||||
|
||||
const { openConfirmDialog: openDialog } = useConfirmDialog();
|
||||
@@ -61,24 +62,24 @@ export default function DomainsList({ app }: {
|
||||
<TableCell className="font-medium">{domain.port}</TableCell>
|
||||
<TableCell className="font-medium">{domain.useSsl ? <CheckIcon /> : <XIcon />}</TableCell>
|
||||
<TableCell className="font-medium">{domain.useSsl && domain.redirectHttps ? <CheckIcon /> : <XIcon />}</TableCell>
|
||||
<TableCell className="font-medium flex gap-2">
|
||||
{!readonly && <TableCell className="font-medium flex gap-2">
|
||||
<DialogEditDialog appId={app.id} domain={domain}>
|
||||
<Button variant="ghost"><EditIcon /></Button>
|
||||
</DialogEditDialog>
|
||||
<Button variant="ghost" onClick={() => asyncDeleteDomain(domain.id)}>
|
||||
<TrashIcon />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableCell>}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
{!readonly && <CardFooter>
|
||||
<DialogEditDialog appId={app.id}>
|
||||
<Button><Plus /> Add Domain</Button>
|
||||
</DialogEditDialog>
|
||||
</CardFooter>
|
||||
</CardFooter>}
|
||||
</Card >
|
||||
|
||||
</>;
|
||||
|
||||
@@ -15,8 +15,9 @@ import { EditIcon, Plus, TrashIcon } from "lucide-react";
|
||||
import { Toast } from "@/frontend/utils/toast.utils";
|
||||
import { useConfirmDialog } from "@/frontend/states/zustand.states";
|
||||
|
||||
export default function InternalHostnames({ app }: {
|
||||
app: AppExtendedModel
|
||||
export default function InternalHostnames({ app, readonly }: {
|
||||
app: AppExtendedModel;
|
||||
readonly: boolean;
|
||||
}) {
|
||||
|
||||
const { openConfirmDialog: openDialog } = useConfirmDialog();
|
||||
@@ -34,7 +35,6 @@ export default function InternalHostnames({ app }: {
|
||||
|
||||
const internalUrl = KubeObjectNameUtils.toServiceName(app.id);
|
||||
|
||||
|
||||
return <>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -42,38 +42,38 @@ export default function InternalHostnames({ app }: {
|
||||
<CardDescription>If you want to connect other apps to this app, you have to configure the internal ports below.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableCaption>{app.appPorts.length} Ports</TableCaption>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Port</TableHead>
|
||||
<TableHead className="w-[100px]">Action</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{app.appPorts.map(port => (
|
||||
<TableRow key={port.id}>
|
||||
<TableCell className="font-medium">
|
||||
{port.port}
|
||||
</TableCell>
|
||||
<TableCell className="font-medium flex gap-2">
|
||||
<DefaultPortEditDialog appId={app.id} appPort={port}>
|
||||
<Button variant="ghost"><EditIcon /></Button>
|
||||
</DefaultPortEditDialog>
|
||||
<Button variant="ghost" onClick={() => asyncDeleteDomain(port.id)}>
|
||||
<TrashIcon />
|
||||
</Button>
|
||||
</TableCell>
|
||||
<Table>
|
||||
<TableCaption>{app.appPorts.length} Ports</TableCaption>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Port</TableHead>
|
||||
<TableHead className="w-[100px]">Action</TableHead>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{app.appPorts.map(port => (
|
||||
<TableRow key={port.id}>
|
||||
<TableCell className="font-medium">
|
||||
{port.port}
|
||||
</TableCell>
|
||||
{!readonly && <TableCell className="font-medium flex gap-2">
|
||||
<DefaultPortEditDialog appId={app.id} appPort={port}>
|
||||
<Button variant="ghost"><EditIcon /></Button>
|
||||
</DefaultPortEditDialog>
|
||||
<Button variant="ghost" onClick={() => asyncDeleteDomain(port.id)}>
|
||||
<TrashIcon />
|
||||
</Button>
|
||||
</TableCell>}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
{!readonly && <CardFooter>
|
||||
<DefaultPortEditDialog appId={app.id}>
|
||||
<Button><Plus /> Add Port</Button>
|
||||
</DefaultPortEditDialog>
|
||||
</CardFooter>
|
||||
</CardFooter>}
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
|
||||
import { AppEnvVariablesModel, appEnvVariablesZodModel } from "@/shared/model/env-edit.model";
|
||||
import appService from "@/server/services/app.service";
|
||||
import { getAuthUserSession, saveFormAction } from "@/server/utils/action-wrapper.utils";
|
||||
import { getAuthUserSession, isAuthorizedWriteForApp, saveFormAction } from "@/server/utils/action-wrapper.utils";
|
||||
|
||||
|
||||
export const saveEnvVariables = async (prevState: any, inputData: AppEnvVariablesModel, appId: string) =>
|
||||
saveFormAction(inputData, appEnvVariablesZodModel, async (validatedData) => {
|
||||
await getAuthUserSession();
|
||||
await isAuthorizedWriteForApp(appId);
|
||||
const existingApp = await appService.getById(appId);
|
||||
await appService.save({
|
||||
...existingApp,
|
||||
|
||||
@@ -16,12 +16,14 @@ import { Textarea } from "@/components/ui/textarea";
|
||||
import { AppExtendedModel } from "@/shared/model/app-extended.model";
|
||||
|
||||
|
||||
export default function EnvEdit({ app }: {
|
||||
app: AppExtendedModel
|
||||
export default function EnvEdit({ app, readonly }: {
|
||||
app: AppExtendedModel;
|
||||
readonly: boolean;
|
||||
}) {
|
||||
const form = useForm<AppEnvVariablesModel>({
|
||||
resolver: zodResolver(appEnvVariablesZodModel),
|
||||
defaultValues: app
|
||||
defaultValues: app,
|
||||
disabled: readonly,
|
||||
});
|
||||
|
||||
const [state, formAction] = useFormState((state: ServerActionResult<any, any>, payload: AppEnvVariablesModel) => saveEnvVariables(state, payload, app.id), FormUtils.getInitialFormState<typeof appEnvVariablesZodModel>());
|
||||
@@ -63,9 +65,9 @@ export default function EnvEdit({ app }: {
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
{!readonly && <CardFooter>
|
||||
<SubmitButton>Save</SubmitButton>
|
||||
</CardFooter>
|
||||
</CardFooter>}
|
||||
</form>
|
||||
</Form >
|
||||
</Card >
|
||||
|
||||
@@ -7,13 +7,13 @@ import { ErrorActionResult, ServerActionResult, SuccessActionResult } from "@/sh
|
||||
import { ServiceException } from "@/shared/model/service.exception.model";
|
||||
import appService from "@/server/services/app.service";
|
||||
import userService from "@/server/services/user.service";
|
||||
import { getAuthUserSession, saveFormAction, simpleAction } from "@/server/utils/action-wrapper.utils";
|
||||
import { getAuthUserSession, isAuthorizedWriteForApp, saveFormAction, simpleAction } from "@/server/utils/action-wrapper.utils";
|
||||
|
||||
|
||||
export const saveGeneralAppSourceInfo = async (prevState: any, inputData: AppSourceInfoInputModel, appId: string) => {
|
||||
if (inputData.sourceType === 'GIT') {
|
||||
return saveFormAction(inputData, appSourceInfoGitZodModel, async (validatedData) => {
|
||||
await getAuthUserSession();
|
||||
await isAuthorizedWriteForApp(appId);
|
||||
const existingApp = await appService.getById(appId);
|
||||
await appService.save({
|
||||
...existingApp,
|
||||
@@ -24,7 +24,7 @@ export const saveGeneralAppSourceInfo = async (prevState: any, inputData: AppSou
|
||||
});
|
||||
} else if (inputData.sourceType === 'CONTAINER') {
|
||||
return saveFormAction(inputData, appSourceInfoContainerZodModel, async (validatedData) => {
|
||||
await getAuthUserSession();
|
||||
await isAuthorizedWriteForApp(appId);
|
||||
const existingApp = await appService.getById(appId);
|
||||
await appService.save({
|
||||
...existingApp,
|
||||
@@ -43,7 +43,7 @@ export const saveGeneralAppRateLimits = async (prevState: any, inputData: AppRat
|
||||
if (validatedData.replicas < 1) {
|
||||
throw new ServiceException('Replica Count must be at least 1');
|
||||
}
|
||||
await getAuthUserSession();
|
||||
await isAuthorizedWriteForApp(appId);
|
||||
|
||||
const extendedApp = await appService.getExtendedById(appId);
|
||||
if (extendedApp.appVolumes.some(v => v.accessMode === 'ReadWriteOnce') && validatedData.replicas > 1) {
|
||||
|
||||
@@ -21,12 +21,14 @@ import { AppExtendedModel } from "@/shared/model/app-extended.model";
|
||||
import { cn } from "@/frontend/utils/utils";
|
||||
|
||||
|
||||
export default function GeneralAppRateLimits({ app }: {
|
||||
app: AppExtendedModel
|
||||
export default function GeneralAppRateLimits({ app, readonly }: {
|
||||
app: AppExtendedModel;
|
||||
readonly: boolean;
|
||||
}) {
|
||||
const form = useForm<AppRateLimitsModel>({
|
||||
resolver: zodResolver(appRateLimitsZodModel),
|
||||
defaultValues: app
|
||||
defaultValues: app,
|
||||
disabled: readonly
|
||||
});
|
||||
|
||||
const [state, formAction] = useFormState((state: ServerActionResult<any, any>, payload: AppRateLimitsModel) => saveGeneralAppRateLimits(state, payload, app.id), FormUtils.getInitialFormState<typeof appRateLimitsZodModel>());
|
||||
@@ -125,10 +127,10 @@ export default function GeneralAppRateLimits({ app }: {
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="gap-4">
|
||||
{!readonly && <CardFooter className="gap-4">
|
||||
<SubmitButton>Save</SubmitButton>
|
||||
<p className="text-red-500">{state?.message}</p>
|
||||
</CardFooter>
|
||||
</CardFooter>}
|
||||
</form>
|
||||
</Form >
|
||||
</Card >
|
||||
|
||||
@@ -18,15 +18,17 @@ import { App } from "@prisma/client";
|
||||
import { toast } from "sonner";
|
||||
import { AppExtendedModel } from "@/shared/model/app-extended.model";
|
||||
|
||||
export default function GeneralAppSource({ app }: {
|
||||
app: AppExtendedModel
|
||||
export default function GeneralAppSource({ app, readonly }: {
|
||||
app: AppExtendedModel;
|
||||
readonly: boolean;
|
||||
}) {
|
||||
const form = useForm<AppSourceInfoInputModel>({
|
||||
resolver: zodResolver(appSourceInfoInputZodModel),
|
||||
defaultValues: {
|
||||
...app,
|
||||
sourceType: app.sourceType as 'GIT' | 'CONTAINER'
|
||||
}
|
||||
},
|
||||
disabled: readonly,
|
||||
});
|
||||
|
||||
const [state, formAction] = useFormState((state: ServerActionResult<any, any>, payload: AppSourceInfoInputModel) => saveGeneralAppSourceInfo(state, payload, app.id), FormUtils.getInitialFormState<typeof appSourceInfoInputZodModel>());
|
||||
@@ -197,10 +199,10 @@ export default function GeneralAppSource({ app }: {
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
<CardFooter className="gap-4">
|
||||
{!readonly && <CardFooter className="gap-4">
|
||||
<SubmitButton>Save</SubmitButton>
|
||||
<p className="text-red-500">{state?.message}</p>
|
||||
</CardFooter>
|
||||
</CardFooter>}
|
||||
</form>
|
||||
</Form >
|
||||
</Card >
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Inter } from "next/font/google";
|
||||
import { getAuthUserSession } from "@/server/utils/action-wrapper.utils";
|
||||
import { isAuthorizedReadForApp } from "@/server/utils/action-wrapper.utils";
|
||||
import appService from "@/server/services/app.service";
|
||||
import {
|
||||
Breadcrumb,
|
||||
@@ -19,11 +19,11 @@ export default async function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
|
||||
await getAuthUserSession();
|
||||
const appId = params?.appId;
|
||||
if (!appId) {
|
||||
return <p>Could not find app with id {appId}</p>
|
||||
}
|
||||
const session = await isAuthorizedReadForApp(appId);
|
||||
const app = await appService.getExtendedById(appId);
|
||||
|
||||
return (
|
||||
@@ -32,7 +32,7 @@ export default async function RootLayout({
|
||||
title={app.name}
|
||||
subtitle={`App ID: ${app.id}`}>
|
||||
</PageTitle>
|
||||
<AppActionButtons app={app} />
|
||||
<AppActionButtons session={session} app={app} />
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -8,55 +8,54 @@ import buildService from "@/server/services/build.service";
|
||||
import deploymentService from "@/server/services/deployment.service";
|
||||
import monitoringService from "@/server/services/monitoring.service";
|
||||
import podService from "@/server/services/pod.service";
|
||||
import { getAuthUserSession, simpleAction } from "@/server/utils/action-wrapper.utils";
|
||||
import { isAuthorizedReadForApp, isAuthorizedWriteForApp, simpleAction } from "@/server/utils/action-wrapper.utils";
|
||||
import { PodsResourceInfoModel } from "@/shared/model/pods-resource-info.model";
|
||||
import appLogsService from "@/server/services/standalone-services/app-logs.service";
|
||||
import { DownloadableAppLogsModel } from "@/shared/model/downloadable-app-logs.model";
|
||||
import { ServiceException } from "@/shared/model/service.exception.model";
|
||||
|
||||
|
||||
export const getDeploymentsAndBuildsForApp = async (appId: string) =>
|
||||
simpleAction(async () => {
|
||||
await getAuthUserSession();
|
||||
await isAuthorizedReadForApp(appId);
|
||||
const app = await appService.getExtendedById(appId);
|
||||
return await deploymentService.getDeploymentHistory(app.projectId, appId);
|
||||
}) as Promise<ServerActionResult<unknown, DeploymentInfoModel[]>>;
|
||||
|
||||
export const deleteBuild = async (buildName: string) =>
|
||||
simpleAction(async () => {
|
||||
await getAuthUserSession();
|
||||
await isAuthorizedWriteForApp(await buildService.getAppIdByBuildName(buildName));
|
||||
await buildService.deleteBuild(buildName);
|
||||
return new SuccessActionResult(undefined, 'Successfully stopped and deleted build.');
|
||||
}) as Promise<ServerActionResult<unknown, void>>;
|
||||
|
||||
export const getPodsForApp = async (appId: string) =>
|
||||
simpleAction(async () => {
|
||||
await getAuthUserSession();
|
||||
await isAuthorizedReadForApp(appId);
|
||||
const app = await appService.getExtendedById(appId);
|
||||
return await podService.getPodsForApp(app.projectId, appId);
|
||||
}) as Promise<ServerActionResult<unknown, PodsInfoModel[]>>;
|
||||
|
||||
export const getRessourceDataApp = async (projectId: string, appId: string) =>
|
||||
simpleAction(async () => {
|
||||
await getAuthUserSession();
|
||||
await isAuthorizedReadForApp(appId);
|
||||
return await monitoringService.getMonitoringForApp(projectId, appId);
|
||||
}) as Promise<ServerActionResult<unknown, PodsResourceInfoModel>>;
|
||||
|
||||
export const createNewWebhookUrl = async (appId: string) =>
|
||||
simpleAction(async () => {
|
||||
await getAuthUserSession();
|
||||
await isAuthorizedWriteForApp(appId);
|
||||
await appService.regenerateWebhookId(appId);
|
||||
});
|
||||
|
||||
export const getDownloadableLogs = async (appId: string) =>
|
||||
simpleAction(async () => {
|
||||
await getAuthUserSession();
|
||||
await isAuthorizedReadForApp(appId);
|
||||
return new SuccessActionResult(await appLogsService.getAvailableLogsForApp(appId));
|
||||
}) as Promise<ServerActionResult<unknown, DownloadableAppLogsModel[]>>;
|
||||
|
||||
export const exportLogsToFileForToday = async (appId: string) =>
|
||||
simpleAction(async () => {
|
||||
await getAuthUserSession();
|
||||
await isAuthorizedReadForApp(appId);
|
||||
const result = await appLogsService.writeAppLogsToDiskForApp(appId);
|
||||
if (!result) {
|
||||
throw new ServiceException('There are no logs available for today.');
|
||||
|
||||
@@ -12,11 +12,14 @@ import { DeploymentInfoModel } from "@/shared/model/deployment-info.model";
|
||||
import DeploymentStatusBadge from "./deployment-status-badge";
|
||||
import { BuildLogsDialog } from "./build-logs-overlay";
|
||||
import ShortCommitHash from "@/components/custom/short-commit-hash";
|
||||
import { RolePermissionEnum } from "@/shared/model/role-extended.model.ts";
|
||||
|
||||
export default function BuildsTab({
|
||||
app
|
||||
app,
|
||||
role
|
||||
}: {
|
||||
app: AppExtendedModel;
|
||||
role: RolePermissionEnum;
|
||||
}) {
|
||||
|
||||
const { openConfirmDialog: openDialog } = useConfirmDialog();
|
||||
@@ -89,7 +92,7 @@ export default function BuildsTab({
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-1"></div>
|
||||
{item.deploymentId && <Button variant="secondary" onClick={() => setSelectedDeploymentForLogs(item)}>Show Logs</Button>}
|
||||
{item.buildJobName && item.status === 'BUILDING' && <Button variant="destructive" onClick={() => deleteBuildClick(item.buildJobName!)}>Stop Build</Button>}
|
||||
{role === RolePermissionEnum.READWRITE && item.buildJobName && item.status === 'BUILDING' && <Button variant="destructive" onClick={() => deleteBuildClick(item.buildJobName!)}>Stop Build</Button>}
|
||||
</div>
|
||||
</>
|
||||
}}
|
||||
|
||||
@@ -13,11 +13,14 @@ import { Download, Expand, Terminal } from "lucide-react";
|
||||
import { TerminalDialog } from "./terminal-overlay";
|
||||
import { LogsDownloadOverlay } from "./logs-download-overlay";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { RolePermissionEnum } from "@/shared/model/role-extended.model.ts";
|
||||
|
||||
export default function Logs({
|
||||
app
|
||||
app,
|
||||
role
|
||||
}: {
|
||||
app: AppExtendedModel;
|
||||
role: RolePermissionEnum;
|
||||
}) {
|
||||
const [selectedPod, setSelectedPod] = useState<PodsInfoModel | undefined>(undefined);
|
||||
const [appPods, setAppPods] = useState<PodsInfoModel[] | undefined>(undefined);
|
||||
@@ -76,7 +79,7 @@ export default function Logs({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
{role === RolePermissionEnum.READWRITE && <div>
|
||||
<TerminalDialog terminalInfo={{
|
||||
podName: selectedPod.podName,
|
||||
containerName: selectedPod.containerName,
|
||||
@@ -86,7 +89,7 @@ export default function Logs({
|
||||
<Terminal /> Terminal
|
||||
</Button>
|
||||
</TerminalDialog>
|
||||
</div>
|
||||
</div>}
|
||||
<div>
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={300}>
|
||||
|
||||
@@ -7,11 +7,14 @@ import { useConfirmDialog } from "@/frontend/states/zustand.states";
|
||||
import { Toast } from "@/frontend/utils/toast.utils";
|
||||
import { ClipboardCopy } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { RolePermissionEnum } from "@/shared/model/role-extended.model.ts";
|
||||
|
||||
export default function WebhookDeploymentInfo({
|
||||
app
|
||||
app,
|
||||
role
|
||||
}: {
|
||||
app: AppExtendedModel;
|
||||
role: RolePermissionEnum;
|
||||
}) {
|
||||
const { openConfirmDialog } = useConfirmDialog();
|
||||
const [webhookUrl, setWebhookUrl] = useState<string | undefined>(undefined);
|
||||
@@ -52,7 +55,7 @@ export default function WebhookDeploymentInfo({
|
||||
{webhookUrl && <Button className="flex-1 truncate" variant="secondary" onClick={copyWebhookUrl}>
|
||||
<span className="truncate">{webhookUrl}</span> <ClipboardCopy />
|
||||
</Button>}
|
||||
<Button onClick={createNewWebhookUrlAsync} variant={webhookUrl ? 'ghost' : 'secondary'}>{webhookUrl ? 'Generate new Webhook URL' : 'Enable Webhook deployments'}</Button>
|
||||
{role === RolePermissionEnum.READWRITE && <Button onClick={createNewWebhookUrlAsync} variant={webhookUrl ? 'ghost' : 'secondary'}>{webhookUrl ? 'Generate new Webhook URL' : 'Enable Webhook deployments'}</Button>}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { getAuthUserSession } from "@/server/utils/action-wrapper.utils";
|
||||
import { getAuthUserSession, isAuthorizedReadForApp } from "@/server/utils/action-wrapper.utils";
|
||||
import appService from "@/server/services/app.service";
|
||||
import AppTabs from "./app-tabs";
|
||||
import AppBreadcrumbs from "./app-breadcrumbs";
|
||||
import s3TargetService from "@/server/services/s3-target.service";
|
||||
import volumeBackupService from "@/server/services/volume-backup.service";
|
||||
import { UserGroupUtils } from "@/shared/utils/role.utils";
|
||||
|
||||
export default async function AppPage({
|
||||
searchParams,
|
||||
@@ -12,11 +13,12 @@ export default async function AppPage({
|
||||
searchParams?: { [key: string]: string | undefined };
|
||||
params: { appId: string }
|
||||
}) {
|
||||
await getAuthUserSession();
|
||||
const appId = params?.appId;
|
||||
if (!appId) {
|
||||
return <p>Could not find app with id {appId}</p>
|
||||
}
|
||||
const session = await isAuthorizedReadForApp(appId);
|
||||
const role = UserGroupUtils.getRolePermissionForApp(session, appId);
|
||||
const [app, s3Targets, volumeBackups] = await Promise.all([
|
||||
appService.getExtendedById(appId),
|
||||
s3TargetService.getAll(),
|
||||
@@ -25,6 +27,7 @@ export default async function AppPage({
|
||||
|
||||
return (<>
|
||||
<AppTabs
|
||||
role={role!}
|
||||
volumeBackups={volumeBackups}
|
||||
s3Targets={s3Targets}
|
||||
app={app}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { appVolumeEditZodModel } from "@/shared/model/volume-edit.model";
|
||||
import { ServerActionResult, SuccessActionResult } from "@/shared/model/server-action-error-return.model";
|
||||
import appService from "@/server/services/app.service";
|
||||
import { getAuthUserSession, saveFormAction, simpleAction } from "@/server/utils/action-wrapper.utils";
|
||||
import { getAuthUserSession, isAuthorizedReadForApp, isAuthorizedWriteForApp, saveFormAction, simpleAction } from "@/server/utils/action-wrapper.utils";
|
||||
import { z } from "zod";
|
||||
import { ServiceException } from "@/shared/model/service.exception.model";
|
||||
import pvcService from "@/server/services/pvc.service";
|
||||
@@ -15,6 +15,7 @@ import { volumeUploadZodModel } from "@/shared/model/volume-upload.model";
|
||||
import restoreService from "@/server/services/restore.service";
|
||||
import fileBrowserService from "@/server/services/file-browser-service";
|
||||
import monitoringService from "@/server/services/monitoring.service";
|
||||
import dataAccess from "@/server/adapter/db.client";
|
||||
|
||||
const actionAppVolumeEditZodModel = appVolumeEditZodModel.merge(z.object({
|
||||
appId: z.string(),
|
||||
@@ -23,7 +24,7 @@ const actionAppVolumeEditZodModel = appVolumeEditZodModel.merge(z.object({
|
||||
|
||||
export const restoreVolumeFromZip = async (prevState: any, inputData: FormData, volumeId: string) =>
|
||||
simpleAction(async () => {
|
||||
await getAuthUserSession();
|
||||
await validateVolumeWriteAuthorization(volumeId);
|
||||
const validatedData = volumeUploadZodModel.parse({
|
||||
volumeId,
|
||||
file: ''
|
||||
@@ -39,7 +40,7 @@ export const restoreVolumeFromZip = async (prevState: any, inputData: FormData,
|
||||
|
||||
export const saveVolume = async (prevState: any, inputData: z.infer<typeof actionAppVolumeEditZodModel>) =>
|
||||
saveFormAction(inputData, actionAppVolumeEditZodModel, async (validatedData) => {
|
||||
await getAuthUserSession();
|
||||
await isAuthorizedWriteForApp(validatedData.appId);
|
||||
const existingApp = await appService.getExtendedById(validatedData.appId);
|
||||
const existingVolume = validatedData.id ? await appService.getVolumeById(validatedData.id) : undefined;
|
||||
if (existingVolume && existingVolume.size > validatedData.size) {
|
||||
@@ -57,20 +58,20 @@ export const saveVolume = async (prevState: any, inputData: z.infer<typeof actio
|
||||
|
||||
export const deleteVolume = async (volumeId: string) =>
|
||||
simpleAction(async () => {
|
||||
await getAuthUserSession();
|
||||
await validateVolumeWriteAuthorization(volumeId);
|
||||
await appService.deleteVolumeById(volumeId);
|
||||
return new SuccessActionResult(undefined, 'Successfully deleted volume');
|
||||
});
|
||||
|
||||
export const getPvcUsage = async (appId: string, projectId: string) =>
|
||||
simpleAction(async () => {
|
||||
await getAuthUserSession();
|
||||
await isAuthorizedReadForApp(appId);
|
||||
return monitoringService.getPvcUsageFromApp(appId, projectId);
|
||||
}) as Promise<ServerActionResult<any, { pvcName: string, usedBytes: number }[]>>;
|
||||
|
||||
export const downloadPvcData = async (volumeId: string) =>
|
||||
simpleAction(async () => {
|
||||
await getAuthUserSession();
|
||||
await validateVolumeReadAuthorization(volumeId);
|
||||
const fileNameOfDownloadedFile = await pvcService.downloadPvcData(volumeId);
|
||||
return new SuccessActionResult(fileNameOfDownloadedFile, 'Successfully zipped volume data'); // returns the download path on the server
|
||||
}) as Promise<ServerActionResult<any, string>>;
|
||||
@@ -82,7 +83,7 @@ const actionAppFileMountEditZodModel = fileMountEditZodModel.merge(z.object({
|
||||
|
||||
export const saveFileMount = async (prevState: any, inputData: z.infer<typeof actionAppFileMountEditZodModel>) =>
|
||||
saveFormAction(inputData, actionAppFileMountEditZodModel, async (validatedData) => {
|
||||
await getAuthUserSession();
|
||||
await isAuthorizedWriteForApp(validatedData.appId);
|
||||
await appService.saveFileMount({
|
||||
...validatedData,
|
||||
id: validatedData.id ?? undefined,
|
||||
@@ -91,14 +92,14 @@ export const saveFileMount = async (prevState: any, inputData: z.infer<typeof ac
|
||||
|
||||
export const deleteFileMount = async (fileMountId: string) =>
|
||||
simpleAction(async () => {
|
||||
await getAuthUserSession();
|
||||
await validateFileMountWriteAuthorization(fileMountId);
|
||||
await appService.deleteFileMountById(fileMountId);
|
||||
return new SuccessActionResult(undefined, 'Successfully deleted volume');
|
||||
});
|
||||
|
||||
export const saveBackupVolume = async (prevState: any, inputData: VolumeBackupEditModel) =>
|
||||
saveFormAction(inputData, volumeBackupEditZodModel, async (validatedData) => {
|
||||
await getAuthUserSession();
|
||||
await validateVolumeWriteAuthorization(validatedData.volumeId);
|
||||
if (validatedData.retention < 1) {
|
||||
throw new ServiceException('Retention must be at least 1');
|
||||
}
|
||||
@@ -112,7 +113,7 @@ export const saveBackupVolume = async (prevState: any, inputData: VolumeBackupEd
|
||||
|
||||
export const deleteBackupVolume = async (backupVolumeId: string) =>
|
||||
simpleAction(async () => {
|
||||
await getAuthUserSession();
|
||||
await validateBackupVolumeWriteAuthorization(backupVolumeId);
|
||||
await volumeBackupService.deleteById(backupVolumeId);
|
||||
await backupService.registerAllBackups();
|
||||
return new SuccessActionResult(undefined, 'Successfully deleted backup schedule');
|
||||
@@ -120,17 +121,69 @@ export const deleteBackupVolume = async (backupVolumeId: string) =>
|
||||
|
||||
export const runBackupVolumeSchedule = async (backupVolumeId: string) =>
|
||||
simpleAction(async () => {
|
||||
await getAuthUserSession();
|
||||
await validateBackupVolumeWriteAuthorization(backupVolumeId);
|
||||
await backupService.runBackupForVolume(backupVolumeId);
|
||||
return new SuccessActionResult(undefined, 'Backup created and uploaded successfully');
|
||||
});
|
||||
|
||||
export const openFileBrowserForVolume = async (volumeId: string) =>
|
||||
simpleAction(async () => {
|
||||
await getAuthUserSession();
|
||||
await validateVolumeWriteAuthorization(volumeId);
|
||||
const fileBrowserDomain = await fileBrowserService.deployFileBrowserForVolume(volumeId);
|
||||
return new SuccessActionResult(fileBrowserDomain, 'File browser started successfully');
|
||||
}) as Promise<ServerActionResult<any, {
|
||||
url: string;
|
||||
password: string;
|
||||
}>>;
|
||||
}>>;
|
||||
|
||||
async function validateVolumeWriteAuthorization(volumeId: string) {
|
||||
const volumeAppId = await dataAccess.client.appVolume.findFirstOrThrow({
|
||||
where: {
|
||||
id: volumeId,
|
||||
},
|
||||
select: {
|
||||
appId: true,
|
||||
}
|
||||
});
|
||||
await isAuthorizedWriteForApp(volumeAppId?.appId);
|
||||
}
|
||||
|
||||
async function validateVolumeReadAuthorization(volumeId: string) {
|
||||
const volumeAppId = await dataAccess.client.appVolume.findFirstOrThrow({
|
||||
where: {
|
||||
id: volumeId,
|
||||
},
|
||||
select: {
|
||||
appId: true,
|
||||
}
|
||||
});
|
||||
await isAuthorizedReadForApp(volumeAppId?.appId);
|
||||
}
|
||||
|
||||
async function validateFileMountWriteAuthorization(fileMountId: string) {
|
||||
const fileMountAppId = await dataAccess.client.appFileMount.findFirstOrThrow({
|
||||
where: {
|
||||
id: fileMountId,
|
||||
},
|
||||
select: {
|
||||
appId: true,
|
||||
}
|
||||
});
|
||||
await isAuthorizedWriteForApp(fileMountAppId?.appId);
|
||||
}
|
||||
|
||||
async function validateBackupVolumeWriteAuthorization(backupVolumeId: string) {
|
||||
const volumeAppId = await dataAccess.client.volumeBackup.findFirstOrThrow({
|
||||
where: {
|
||||
id: backupVolumeId,
|
||||
},
|
||||
select: {
|
||||
volume: {
|
||||
select: {
|
||||
appId: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
await isAuthorizedWriteForApp(volumeAppId?.volume.appId);
|
||||
}
|
||||
@@ -14,8 +14,9 @@ import FileMountEditDialog from "./file-mount-edit-dialog";
|
||||
|
||||
type AppVolumeWithCapacity = (AppVolume & { capacity?: string });
|
||||
|
||||
export default function FileMount({ app }: {
|
||||
app: AppExtendedModel
|
||||
export default function FileMount({ app, readonly }: {
|
||||
app: AppExtendedModel;
|
||||
readonly: boolean;
|
||||
}) {
|
||||
|
||||
const { openConfirmDialog: openDialog } = useConfirmDialog();
|
||||
@@ -50,24 +51,25 @@ export default function FileMount({ app }: {
|
||||
{app.appFileMounts.map(fileMount => (
|
||||
<TableRow key={fileMount.containerMountPath}>
|
||||
<TableCell className="font-medium">{fileMount.containerMountPath}</TableCell>
|
||||
<TableCell className="font-medium flex gap-2">
|
||||
{!readonly && <TableCell className="font-medium flex gap-2">
|
||||
<FileMountEditDialog app={app} fileMount={fileMount}>
|
||||
<Button variant="ghost"><EditIcon /></Button>
|
||||
</FileMountEditDialog>
|
||||
<Button variant="ghost" onClick={() => asyncDeleteFileMount(fileMount.id)}>
|
||||
<TrashIcon />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableCell>}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
{!readonly && <CardFooter>
|
||||
<FileMountEditDialog app={app}>
|
||||
<Button>Add File Mount</Button>
|
||||
</FileMountEditDialog>
|
||||
</CardFooter>
|
||||
}
|
||||
</Card >
|
||||
</>;
|
||||
}
|
||||
@@ -25,8 +25,9 @@ import { Progress } from "@/components/ui/progress";
|
||||
|
||||
type AppVolumeWithCapacity = (AppVolume & { usedBytes?: number; capacityBytes?: number; usedPercentage?: number });
|
||||
|
||||
export default function StorageList({ app }: {
|
||||
app: AppExtendedModel
|
||||
export default function StorageList({ app, readonly }: {
|
||||
app: AppExtendedModel;
|
||||
readonly: boolean;
|
||||
}) {
|
||||
|
||||
const [volumesWithStorage, setVolumesWithStorage] = React.useState<AppVolumeWithCapacity[]>(app.appVolumes);
|
||||
@@ -181,7 +182,7 @@ export default function StorageList({ app }: {
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<TooltipProvider>
|
||||
{!readonly && <TooltipProvider>
|
||||
<Tooltip delayDuration={200}>
|
||||
<TooltipTrigger>
|
||||
<Button variant="ghost" onClick={() => openFileBrowserForVolumeAsync(volume.id)} disabled={isLoading}>
|
||||
@@ -192,7 +193,7 @@ export default function StorageList({ app }: {
|
||||
<p>View content of Volume</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</TooltipProvider>}
|
||||
{/*<StorageRestoreDialog app={app} volume={volume}>
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={200}>
|
||||
@@ -207,41 +208,43 @@ export default function StorageList({ app }: {
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</StorageRestoreDialog>*/}
|
||||
<DialogEditDialog app={app} volume={volume}>
|
||||
{!readonly && <>
|
||||
<DialogEditDialog app={app} volume={volume}>
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={200}>
|
||||
<TooltipTrigger>
|
||||
<Button variant="ghost" disabled={isLoading}><EditIcon /></Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Edit volume settings</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</DialogEditDialog>
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={200}>
|
||||
<TooltipTrigger>
|
||||
<Button variant="ghost" disabled={isLoading}><EditIcon /></Button>
|
||||
<Button variant="ghost" onClick={() => asyncDeleteVolume(volume.id)} disabled={isLoading}>
|
||||
<TrashIcon />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Edit volume settings</p>
|
||||
<p>Delete volume</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</DialogEditDialog>
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={200}>
|
||||
<TooltipTrigger>
|
||||
<Button variant="ghost" onClick={() => asyncDeleteVolume(volume.id)} disabled={isLoading}>
|
||||
<TrashIcon />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Delete volume</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</>}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
{!readonly && <CardFooter>
|
||||
<DialogEditDialog app={app}>
|
||||
<Button>Add Volume</Button>
|
||||
</DialogEditDialog>
|
||||
</CardFooter>
|
||||
</CardFooter>}
|
||||
</Card >
|
||||
</>;
|
||||
}
|
||||
@@ -17,11 +17,13 @@ import { VolumeBackupExtendedModel } from "@/shared/model/volume-backup-extended
|
||||
export default function VolumeBackupList({
|
||||
app,
|
||||
volumeBackups,
|
||||
s3Targets
|
||||
s3Targets,
|
||||
readonly
|
||||
}: {
|
||||
app: AppExtendedModel,
|
||||
s3Targets: S3Target[],
|
||||
volumeBackups: VolumeBackupExtendedModel[]
|
||||
volumeBackups: VolumeBackupExtendedModel[];
|
||||
readonly: boolean;
|
||||
}) {
|
||||
|
||||
const { openConfirmDialog: openDialog } = useConfirmDialog();
|
||||
@@ -79,7 +81,7 @@ export default function VolumeBackupList({
|
||||
<TableCell className="font-medium">{volumeBackup.retention}</TableCell>
|
||||
<TableCell className="font-medium">{volumeBackup.target.name}</TableCell>
|
||||
<TableCell className="font-medium">{formatDateTime(volumeBackup.createdAt)}</TableCell>
|
||||
<TableCell className="font-medium flex gap-2">
|
||||
{!readonly && <TableCell className="font-medium flex gap-2">
|
||||
<Button disabled={isLoading} variant="ghost" onClick={() => asyncRunBackupVolumeSchedule(volumeBackup.id)}>
|
||||
<Play />
|
||||
</Button>
|
||||
@@ -90,17 +92,17 @@ export default function VolumeBackupList({
|
||||
<Button disabled={isLoading} variant="ghost" onClick={() => asyncDeleteBackupVolume(volumeBackup.id)}>
|
||||
<TrashIcon />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableCell>}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
{!readonly && <CardFooter>
|
||||
<VolumeBackupEditDialog s3Targets={s3Targets} volumes={app.appVolumes}>
|
||||
<Button>Add Backup Schedule</Button>
|
||||
</VolumeBackupEditDialog>
|
||||
</CardFooter>
|
||||
</CardFooter>}
|
||||
</Card >
|
||||
</>;
|
||||
}
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
import { SuccessActionResult } from "@/shared/model/server-action-error-return.model";
|
||||
import projectService from "@/server/services/project.service";
|
||||
import { getAuthUserSession, saveFormAction, simpleAction } from "@/server/utils/action-wrapper.utils";
|
||||
import { getAdminUserSession, getAuthUserSession, saveFormAction, simpleAction } from "@/server/utils/action-wrapper.utils";
|
||||
import { z } from "zod";
|
||||
import { UserGroupUtils } from "@/shared/utils/role.utils";
|
||||
import { ServiceException } from "@/shared/model/service.exception.model";
|
||||
|
||||
const createProjectSchema = z.object({
|
||||
projectName: z.string().min(1),
|
||||
@@ -12,7 +14,7 @@ const createProjectSchema = z.object({
|
||||
|
||||
export const createProject = async (projectName: string, projectId?: string) =>
|
||||
saveFormAction({ projectName, projectId }, createProjectSchema, async (validatedData) => {
|
||||
await getAuthUserSession();
|
||||
const session = await getAdminUserSession();
|
||||
await projectService.save({
|
||||
id: validatedData.projectId ?? undefined,
|
||||
name: validatedData.projectName
|
||||
@@ -22,7 +24,7 @@ export const createProject = async (projectName: string, projectId?: string) =>
|
||||
|
||||
export const deleteProject = async (projectId: string) =>
|
||||
simpleAction(async () => {
|
||||
await getAuthUserSession();
|
||||
await getAdminUserSession();
|
||||
await projectService.deleteById(projectId);
|
||||
return new SuccessActionResult(undefined, "Project deleted successfully.");
|
||||
});
|
||||
@@ -18,21 +18,24 @@ import {
|
||||
import { useBreadcrumbs } from "@/frontend/states/zustand.states";
|
||||
import ProjectsBreadcrumbs from "./projects-breadcrumbs";
|
||||
import { Plus } from "lucide-react";
|
||||
import { UserGroupUtils } from "@/shared/utils/role.utils";
|
||||
|
||||
export default async function ProjectPage() {
|
||||
|
||||
await getAuthUserSession();
|
||||
const session = await getAuthUserSession();
|
||||
const data = await projectService.getAllProjects();
|
||||
const relevantProjectsForUser = data.filter((project) =>
|
||||
UserGroupUtils.sessionHasReadAccessToProject(session, project.id));
|
||||
|
||||
return (
|
||||
<div className="flex-1 space-y-4 pt-6">
|
||||
<div className="flex gap-4">
|
||||
<h2 className="text-3xl font-bold tracking-tight flex-1">Projects</h2>
|
||||
<EditProjectDialog>
|
||||
{UserGroupUtils.isAdmin(session) && <EditProjectDialog>
|
||||
<Button><Plus /> Create Project</Button>
|
||||
</EditProjectDialog>
|
||||
</EditProjectDialog>}
|
||||
</div>
|
||||
<ProjectsTable data={data} />
|
||||
<ProjectsTable session={session} data={relevantProjectsForUser} />
|
||||
<ProjectsBreadcrumbs />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -10,13 +10,13 @@ import { Edit2, Eye, MoreHorizontal, Trash } from "lucide-react";
|
||||
import { Toast } from "@/frontend/utils/toast.utils";
|
||||
import { Project } from "@prisma/client";
|
||||
import { deleteProject } from "./actions";
|
||||
import { useBreadcrumbs, useConfirmDialog } from "@/frontend/states/zustand.states";
|
||||
import { useEffect } from "react";
|
||||
import { useConfirmDialog } from "@/frontend/states/zustand.states";
|
||||
import { EditProjectDialog } from "./edit-project-dialog";
|
||||
import { UserSession } from "@/shared/model/sim-session.model";
|
||||
import { UserGroupUtils } from "@/shared/utils/role.utils";
|
||||
|
||||
|
||||
|
||||
export default function ProjectsTable({ data }: { data: Project[] }) {
|
||||
export default function ProjectsTable({ data, session }: { data: Project[]; session: UserSession; }) {
|
||||
|
||||
const { openConfirmDialog: openDialog } = useConfirmDialog();
|
||||
|
||||
@@ -59,14 +59,16 @@ export default function ProjectsTable({ data }: { data: Project[] }) {
|
||||
</DropdownMenuItem>
|
||||
</Link>
|
||||
<DropdownMenuSeparator />
|
||||
<EditProjectDialog existingItem={item}>
|
||||
<DropdownMenuItem>
|
||||
<Edit2 /> <span>Edit Project Name</span>
|
||||
{UserGroupUtils.isAdmin(session) && <>
|
||||
<EditProjectDialog existingItem={item}>
|
||||
<DropdownMenuItem>
|
||||
<Edit2 /> <span>Edit Project Name</span>
|
||||
</DropdownMenuItem>
|
||||
</EditProjectDialog>
|
||||
<DropdownMenuItem className="text-red-500" onClick={() => asyncDeleteProject(item.id)}>
|
||||
<Trash /> <span >Delete Project</span>
|
||||
</DropdownMenuItem>
|
||||
</EditProjectDialog>
|
||||
<DropdownMenuItem className="text-red-500" onClick={() => asyncDeleteProject(item.id)}>
|
||||
<Trash /> <span >Delete Project</span>
|
||||
</DropdownMenuItem>
|
||||
</>}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
'use server'
|
||||
|
||||
import { getAuthUserSession, simpleAction } from "@/server/utils/action-wrapper.utils";
|
||||
import { getAdminUserSession, getAuthUserSession, simpleAction } from "@/server/utils/action-wrapper.utils";
|
||||
import { SuccessActionResult } from "@/shared/model/server-action-error-return.model";
|
||||
import clusterService from "@/server/services/node.service";
|
||||
|
||||
export const setNodeStatus = async (nodeName: string, schedulable: boolean) =>
|
||||
simpleAction(async () => {
|
||||
await getAuthUserSession();
|
||||
await getAdminUserSession();
|
||||
await clusterService.setNodeStatus(nodeName, schedulable);
|
||||
return new SuccessActionResult(undefined, 'Successfully updated node status.');
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use server'
|
||||
|
||||
import { getAuthUserSession } from "@/server/utils/action-wrapper.utils";
|
||||
import { getAdminUserSession, getAuthUserSession } from "@/server/utils/action-wrapper.utils";
|
||||
import PageTitle from "@/components/custom/page-title";
|
||||
import clusterService from "@/server/services/node.service";
|
||||
import NodeInfo from "./nodeInfo";
|
||||
@@ -11,7 +11,7 @@ import BreadcrumbSetter from "@/components/breadcrumbs-setter";
|
||||
|
||||
export default async function ClusterInfoPage() {
|
||||
|
||||
const session = await getAuthUserSession();
|
||||
const session = await getAdminUserSession();
|
||||
const nodeInfo = await clusterService.getNodeInfo();
|
||||
const clusterJoinToken = await paramService.getString(ParamService.K3S_JOIN_TOKEN);
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use server'
|
||||
|
||||
import { getAuthUserSession } from "@/server/utils/action-wrapper.utils";
|
||||
import { getAdminUserSession, getAuthUserSession } from "@/server/utils/action-wrapper.utils";
|
||||
import PageTitle from "@/components/custom/page-title";
|
||||
import paramService, { ParamService } from "@/server/services/param.service";
|
||||
import podService from "@/server/services/pod.service";
|
||||
@@ -13,7 +13,7 @@ import quickStackService from "@/server/services/qs.service";
|
||||
|
||||
export default async function MaintenancePage() {
|
||||
|
||||
await getAuthUserSession();
|
||||
await getAdminUserSession();
|
||||
const useCanaryChannel = await paramService.getBoolean(ParamService.USE_CANARY_CHANNEL, false);
|
||||
const qsPodInfos = await podService.getPodsForApp(Constants.QS_NAMESPACE, Constants.QS_APP_NAME);
|
||||
const qsPodInfo = qsPodInfos.find(p => !!p);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use server'
|
||||
|
||||
import { SuccessActionResult } from "@/shared/model/server-action-error-return.model";
|
||||
import { getAuthUserSession, saveFormAction, simpleAction } from "@/server/utils/action-wrapper.utils";
|
||||
import { getAdminUserSession, getAuthUserSession, saveFormAction, simpleAction } from "@/server/utils/action-wrapper.utils";
|
||||
import { S3TargetEditModel, s3TargetEditZodModel } from "@/shared/model/s3-target-edit.model";
|
||||
import s3TargetService from "@/server/services/s3-target.service";
|
||||
import s3Service from "@/server/services/aws-s3.service";
|
||||
@@ -10,7 +10,7 @@ import { ServiceException } from "@/shared/model/service.exception.model";
|
||||
|
||||
export const saveS3Target = async (prevState: any, inputData: S3TargetEditModel) =>
|
||||
saveFormAction(inputData, s3TargetEditZodModel, async (validatedData) => {
|
||||
await getAuthUserSession();
|
||||
await getAdminUserSession();
|
||||
|
||||
const url = new URL(validatedData.endpoint.includes('://') ? validatedData.endpoint : `https://${validatedData.endpoint}`);
|
||||
validatedData.endpoint = url.hostname;
|
||||
@@ -27,7 +27,7 @@ export const saveS3Target = async (prevState: any, inputData: S3TargetEditModel)
|
||||
|
||||
export const deleteS3Target = async (s3TargetId: string) =>
|
||||
simpleAction(async () => {
|
||||
await getAuthUserSession();
|
||||
await getAdminUserSession();
|
||||
await s3TargetService.deleteById(s3TargetId);
|
||||
return new SuccessActionResult(undefined, 'Successfully deleted S3 Target');
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
'use server'
|
||||
|
||||
import { getAuthUserSession } from "@/server/utils/action-wrapper.utils";
|
||||
import { getAdminUserSession, getAuthUserSession } from "@/server/utils/action-wrapper.utils";
|
||||
import PageTitle from "@/components/custom/page-title";
|
||||
import s3TargetService from "@/server/services/s3-target.service";
|
||||
import S3TargetsTable from "./s3-targets-table";
|
||||
@@ -10,7 +10,7 @@ import BreadcrumbSetter from "@/components/breadcrumbs-setter";
|
||||
|
||||
export default async function S3TargetsPage() {
|
||||
|
||||
await getAuthUserSession();
|
||||
await getAdminUserSession();
|
||||
const data = await s3TargetService.getAll();
|
||||
return (
|
||||
<div className="flex-1 space-y-4 pt-6">
|
||||
|
||||
@@ -42,7 +42,6 @@ export default function S3TargetsTable({ targets }: {
|
||||
["updatedAt", "Updated At", false, (item) => formatDateTime(item.updatedAt)],
|
||||
]}
|
||||
data={targets}
|
||||
onItemClickLink={(item) => `/project/${item.id}`}
|
||||
actionCol={(item) =>
|
||||
<>
|
||||
<div className="flex">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use server'
|
||||
|
||||
import { getAuthUserSession, saveFormAction, simpleAction } from "@/server/utils/action-wrapper.utils";
|
||||
import { getAdminUserSession, getAuthUserSession, saveFormAction, simpleAction } from "@/server/utils/action-wrapper.utils";
|
||||
import paramService, { ParamService } from "@/server/services/param.service";
|
||||
import { QsIngressSettingsModel, qsIngressSettingsZodModel } from "@/shared/model/qs-settings.model";
|
||||
import { QsLetsEncryptSettingsModel, qsLetsEncryptSettingsZodModel } from "@/shared/model/qs-letsencrypt-settings.model";
|
||||
@@ -20,7 +20,7 @@ import appLogsService from "@/server/services/standalone-services/app-logs.servi
|
||||
|
||||
export const updateIngressSettings = async (prevState: any, inputData: QsIngressSettingsModel) =>
|
||||
saveFormAction(inputData, qsIngressSettingsZodModel, async (validatedData) => {
|
||||
await getAuthUserSession();
|
||||
await getAdminUserSession();
|
||||
|
||||
const url = new URL(validatedData.serverUrl.includes('://') ? validatedData.serverUrl : `https://${validatedData.serverUrl}`);
|
||||
|
||||
@@ -41,7 +41,7 @@ export const updateIngressSettings = async (prevState: any, inputData: QsIngress
|
||||
|
||||
export const updatePublicIpv4Settings = async (prevState: any, inputData: QsPublicIpv4SettingsModel) =>
|
||||
saveFormAction(inputData, qsPublicIpv4SettingsZodModel, async (validatedData) => {
|
||||
await getAuthUserSession();
|
||||
await getAdminUserSession();
|
||||
|
||||
await paramService.save({
|
||||
name: ParamService.PUBLIC_IPV4_ADDRESS,
|
||||
@@ -52,7 +52,7 @@ export const updatePublicIpv4Settings = async (prevState: any, inputData: QsPubl
|
||||
|
||||
export const updatePublicIpv4SettingsAutomatically = async () =>
|
||||
simpleAction(async () => {
|
||||
await getAuthUserSession();
|
||||
await getAdminUserSession();
|
||||
|
||||
const publicIpv4 = await ipAddressFinderAdapter.getPublicIpOfServer();
|
||||
await paramService.save({
|
||||
@@ -63,7 +63,7 @@ export const updatePublicIpv4SettingsAutomatically = async () =>
|
||||
|
||||
export const updateLetsEncryptSettings = async (prevState: any, inputData: QsLetsEncryptSettingsModel) =>
|
||||
saveFormAction(inputData, qsLetsEncryptSettingsZodModel, async (validatedData) => {
|
||||
await getAuthUserSession();
|
||||
await getAdminUserSession();
|
||||
|
||||
await paramService.save({
|
||||
name: ParamService.LETS_ENCRYPT_MAIL,
|
||||
@@ -75,7 +75,7 @@ export const updateLetsEncryptSettings = async (prevState: any, inputData: QsLet
|
||||
|
||||
export const getConfiguredHostname: () => Promise<ServerActionResult<unknown, string | undefined>> = async () =>
|
||||
simpleAction(async () => {
|
||||
await getAuthUserSession();
|
||||
await getAdminUserSession();
|
||||
|
||||
return await paramService.getString(ParamService.QS_SERVER_HOSTNAME);
|
||||
});
|
||||
@@ -83,21 +83,21 @@ export const getConfiguredHostname: () => Promise<ServerActionResult<unknown, st
|
||||
|
||||
export const cleanupOldTmpFiles = async () =>
|
||||
simpleAction(async () => {
|
||||
await getAuthUserSession();
|
||||
await getAdminUserSession();
|
||||
await maintenanceService.deleteAllTempFiles();
|
||||
return new SuccessActionResult(undefined, 'Successfully cleaned up temp files.');
|
||||
});
|
||||
|
||||
export const cleanupOldBuildJobs = async () =>
|
||||
simpleAction(async () => {
|
||||
await getAuthUserSession();
|
||||
await getAdminUserSession();
|
||||
await buildService.deleteAllFailedOrSuccededBuilds();
|
||||
return new SuccessActionResult(undefined, 'Successfully cleaned up old build jobs.');
|
||||
});
|
||||
|
||||
export const updateQuickstack = async () =>
|
||||
simpleAction(async () => {
|
||||
await getAuthUserSession();
|
||||
await getAdminUserSession();
|
||||
const useCaranyChannel = await paramService.getBoolean(ParamService.USE_CANARY_CHANNEL, false);
|
||||
await quickStackService.updateQuickStack(useCaranyChannel);
|
||||
return new SuccessActionResult(undefined, 'QuickStack will be updated, refresh the page in a few seconds.');
|
||||
@@ -105,7 +105,7 @@ export const updateQuickstack = async () =>
|
||||
|
||||
export const updateRegistry = async () =>
|
||||
simpleAction(async () => {
|
||||
await getAuthUserSession();
|
||||
await getAdminUserSession();
|
||||
const registryLocation = await paramService.getString(ParamService.REGISTRY_SOTRAGE_LOCATION, Constants.INTERNAL_REGISTRY_LOCATION);
|
||||
await registryService.deployRegistry(registryLocation!, true);
|
||||
return new SuccessActionResult(undefined, 'Registry will be updated, this might take a few seconds.');
|
||||
@@ -113,35 +113,35 @@ export const updateRegistry = async () =>
|
||||
|
||||
export const updateTraefikMeCertificates = async () =>
|
||||
simpleAction(async () => {
|
||||
await getAuthUserSession();
|
||||
await getAdminUserSession();
|
||||
await traefikMeDomainStandaloneService.updateTraefikMeCertificate();
|
||||
return new SuccessActionResult(undefined, 'Certificates will be updated, this might take a few seconds.');
|
||||
});
|
||||
|
||||
export const deleteAllFailedAndSuccededPods = async () =>
|
||||
simpleAction(async () => {
|
||||
await getAuthUserSession();
|
||||
await getAdminUserSession();
|
||||
await standalonePodService.deleteAllFailedAndSuccededPods();
|
||||
return new SuccessActionResult(undefined, 'Successfully deleted all failed and succeeded pods.');
|
||||
});
|
||||
|
||||
export const purgeRegistryImages = async () =>
|
||||
simpleAction(async () => {
|
||||
await getAuthUserSession();
|
||||
await getAdminUserSession();
|
||||
const deletedSize = await registryService.purgeRegistryImages();
|
||||
return new SuccessActionResult(undefined, `Successfully purged ${KubeSizeConverter.convertBytesToReadableSize(deletedSize)} of images.`);
|
||||
});
|
||||
|
||||
export const deleteOldAppLogs = async () =>
|
||||
simpleAction(async () => {
|
||||
await getAuthUserSession();
|
||||
await getAdminUserSession();
|
||||
await appLogsService.deleteOldAppLogs();
|
||||
return new SuccessActionResult(undefined, `Successfully deletes old app logs.`);
|
||||
});
|
||||
|
||||
export const setCanaryChannel = async (useCanaryChannel: boolean) =>
|
||||
simpleAction(async () => {
|
||||
await getAuthUserSession();
|
||||
await getAdminUserSession();
|
||||
await paramService.save({
|
||||
name: ParamService.USE_CANARY_CHANNEL,
|
||||
value: !!useCanaryChannel ? 'true' : 'false'
|
||||
@@ -151,7 +151,7 @@ export const setCanaryChannel = async (useCanaryChannel: boolean) =>
|
||||
|
||||
export const setRegistryStorageLocation = async (prevState: any, inputData: RegistryStorageLocationSettingsModel) =>
|
||||
saveFormAction(inputData, registryStorageLocationSettingsZodModel, async (validatedData) => {
|
||||
await getAuthUserSession();
|
||||
await getAdminUserSession();
|
||||
|
||||
await registryService.deployRegistry(validatedData.registryStorageLocation, true);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use server'
|
||||
|
||||
import { getAuthUserSession } from "@/server/utils/action-wrapper.utils";
|
||||
import { getAdminUserSession, getAuthUserSession } from "@/server/utils/action-wrapper.utils";
|
||||
import PageTitle from "@/components/custom/page-title";
|
||||
import paramService, { ParamService } from "@/server/services/param.service";
|
||||
import QuickStackIngressSettings from "./qs-ingress-settings";
|
||||
@@ -13,7 +13,7 @@ import BreadcrumbSetter from "@/components/breadcrumbs-setter";
|
||||
|
||||
export default async function ProjectPage() {
|
||||
|
||||
const session = await getAuthUserSession();
|
||||
const session = await getAdminUserSession();
|
||||
const serverUrl = await paramService.getString(ParamService.QS_SERVER_HOSTNAME, '');
|
||||
const disableNodePortAccess = await paramService.getBoolean(ParamService.DISABLE_NODEPORT_ACCESS, false);
|
||||
const letsEncryptMail = await paramService.getString(ParamService.LETS_ENCRYPT_MAIL, session.email);
|
||||
|
||||
86
src/app/settings/users/actions.ts
Normal file
86
src/app/settings/users/actions.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
'use server'
|
||||
|
||||
import { SuccessActionResult } from "@/shared/model/server-action-error-return.model";
|
||||
import { getAdminUserSession, getAuthUserSession, saveFormAction, simpleAction } from "@/server/utils/action-wrapper.utils";
|
||||
import { ServiceException } from "@/shared/model/service.exception.model";
|
||||
import userService from "@/server/services/user.service";
|
||||
import { UserEditModel, userEditZodModel } from "@/shared/model/user-edit.model";
|
||||
import userGroupService from "@/server/services/user-group.service";
|
||||
import { RoleEditModel, roleEditZodModel } from "@/shared/model/role-edit.model";
|
||||
import { adminRoleName } from "@/shared/model/role-extended.model.ts";
|
||||
|
||||
export const saveUser = async (prevState: any, inputData: UserEditModel) =>
|
||||
saveFormAction(inputData, userEditZodModel, async (validatedData) => {
|
||||
const { email } = await getAdminUserSession();
|
||||
if (validatedData.email === email) {
|
||||
throw new ServiceException('Please edit your profile in the profile settings');
|
||||
}
|
||||
if (validatedData.id) {
|
||||
if (!!validatedData.newPassword) {
|
||||
await userService.changePasswordImediately(validatedData.email, validatedData.newPassword);
|
||||
}
|
||||
await userService.updateUser({
|
||||
userGroupId: validatedData.userGroupId,
|
||||
email: validatedData.email
|
||||
});
|
||||
} else {
|
||||
if (!validatedData.newPassword || validatedData.newPassword.split(' ').join('').length === 0) {
|
||||
throw new ServiceException('The password is required');
|
||||
}
|
||||
await userService.registerUser(validatedData.email, validatedData.newPassword, validatedData.userGroupId);
|
||||
}
|
||||
return new SuccessActionResult();
|
||||
});
|
||||
|
||||
export const saveRole = async (prevState: any, inputData: RoleEditModel) =>
|
||||
saveFormAction(inputData, roleEditZodModel, async (validatedData) => {
|
||||
await getAdminUserSession();
|
||||
await userGroupService.saveWithPermissions(validatedData);
|
||||
return new SuccessActionResult();
|
||||
});
|
||||
|
||||
export const deleteUser = async (userId: string) =>
|
||||
simpleAction(async () => {
|
||||
const session = await getAdminUserSession();
|
||||
const user = await userService.getUserById(userId);
|
||||
if (user.email === session.email) {
|
||||
throw new ServiceException('You cannot delete your own user');
|
||||
}
|
||||
if (user.userGroup?.name === adminRoleName) {
|
||||
throw new ServiceException('You cannot delete users with the group "admin"');
|
||||
}
|
||||
await userService.deleteUserById(userId);
|
||||
return new SuccessActionResult();
|
||||
});
|
||||
|
||||
export const assignRoleToUsers = async (userIds: string[], userGroupId: string) =>
|
||||
simpleAction(async () => {
|
||||
await getAdminUserSession();
|
||||
const users = await userService.getAllUsers();
|
||||
for (const user of users) {
|
||||
if (userIds.includes(user.id)) {
|
||||
user.userGroupId = userGroupId;
|
||||
}
|
||||
}
|
||||
|
||||
// check if there are any admin users left
|
||||
const adminRole = await userGroupService.getOrCreateAdminRole();
|
||||
if (!users.some(user => user.userGroupId === adminRole.id)) {
|
||||
throw new ServiceException('You cannot perform this group assignment, because there are no admin users left after this operation.');
|
||||
}
|
||||
|
||||
// save all users with new role
|
||||
const relevantUsers = users.filter(user => userIds.includes(user.id));
|
||||
for (const user of relevantUsers) {
|
||||
await userGroupService.assignUserToRole(user.id, userGroupId);
|
||||
}
|
||||
|
||||
return new SuccessActionResult();
|
||||
});
|
||||
|
||||
export const deleteRole = async (roleId: string) =>
|
||||
simpleAction(async () => {
|
||||
await getAdminUserSession();
|
||||
await userGroupService.deleteById(roleId);
|
||||
return new SuccessActionResult();
|
||||
});
|
||||
51
src/app/settings/users/page.tsx
Normal file
51
src/app/settings/users/page.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
'use server'
|
||||
|
||||
import { getAdminUserSession, getAuthUserSession } from "@/server/utils/action-wrapper.utils";
|
||||
import PageTitle from "@/components/custom/page-title";
|
||||
import S3TargetEditOverlay from "./user-edit-overlay";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import BreadcrumbSetter from "@/components/breadcrumbs-setter";
|
||||
import UsersTable from "./users-table";
|
||||
import userService from "@/server/services/user.service";
|
||||
import userGroupService from "@/server/services/user-group.service";
|
||||
import { CircleUser, UserRoundCog } from "lucide-react";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@/components/ui/tabs"
|
||||
import UserGroupsTable from "./user-groups-table";
|
||||
import appService from "@/server/services/app.service";
|
||||
import projectService from "@/server/services/project.service";
|
||||
|
||||
export default async function UsersAndRolesPage() {
|
||||
|
||||
const session = await getAdminUserSession();
|
||||
const users = await userService.getAllUsers();
|
||||
const userGroups = await userGroupService.getAll();
|
||||
const allApps = await projectService.getAllProjects();
|
||||
return (
|
||||
<div className="flex-1 space-y-4 pt-6">
|
||||
<PageTitle
|
||||
title={'Users & Groups'} >
|
||||
</PageTitle>
|
||||
<BreadcrumbSetter items={[
|
||||
{ name: "Settings", url: "/settings/profile" },
|
||||
{ name: "Users & Groups" },
|
||||
]} />
|
||||
<Tabs defaultValue="users" >
|
||||
<TabsList className="">
|
||||
<TabsTrigger className="px-8 gap-1.5" value="users"><CircleUser className="w-3.5 h-3.5" /> Users</TabsTrigger>
|
||||
<TabsTrigger className="px-8 gap-1.5" value="groups"><UserRoundCog className="w-3.5 h-3.5" /> Groups</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="users">
|
||||
<UsersTable session={session} users={users} userGroups={userGroups} />
|
||||
</TabsContent>
|
||||
<TabsContent value="groups">
|
||||
<UserGroupsTable projects={allApps} userGroups={userGroups} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
140
src/app/settings/users/user-edit-overlay.tsx
Normal file
140
src/app/settings/users/user-edit-overlay.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
'use client'
|
||||
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { useFormState } from 'react-dom'
|
||||
import { useEffect, useState } from "react";
|
||||
import { FormUtils } from "@/frontend/utils/form.utilts";
|
||||
import { SubmitButton } from "@/components/custom/submit-button";
|
||||
import { S3Target, User } from "@prisma/client"
|
||||
import { ServerActionResult } from "@/shared/model/server-action-error-return.model"
|
||||
import { toast } from "sonner"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { UserEditModel, userEditZodModel } from "@/shared/model/user-edit.model"
|
||||
import { UserExtended } from "@/shared/model/user-extended.model"
|
||||
import { saveUser } from "./actions"
|
||||
import SelectFormField from "@/components/custom/select-form-field"
|
||||
import { UserGroupExtended } from "@/shared/model/sim-session.model"
|
||||
|
||||
|
||||
export default function UserEditOverlay({ children, user, userGroups }: {
|
||||
children: React.ReactNode;
|
||||
userGroups: UserGroupExtended[];
|
||||
user?: UserExtended;
|
||||
}) {
|
||||
|
||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||
|
||||
|
||||
const form = useForm<UserEditModel>({
|
||||
resolver: zodResolver(userEditZodModel),
|
||||
defaultValues: user
|
||||
});
|
||||
|
||||
const [state, formAction] = useFormState((state: ServerActionResult<any, any>,
|
||||
payload: UserEditModel) =>
|
||||
saveUser(state, {
|
||||
...payload,
|
||||
id: user?.id
|
||||
}), FormUtils.getInitialFormState<typeof userEditZodModel>());
|
||||
|
||||
useEffect(() => {
|
||||
if (state.status === 'success') {
|
||||
form.reset();
|
||||
toast.success('User saved successfully');
|
||||
setIsOpen(false);
|
||||
}
|
||||
FormUtils.mapValidationErrorsToForm<typeof userEditZodModel>(state, form);
|
||||
}, [state]);
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
form.reset(user);
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div onClick={() => setIsOpen(true)}>
|
||||
{children}
|
||||
</div>
|
||||
<Dialog open={!!isOpen} onOpenChange={(isOpened) => setIsOpen(false)}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{user?.id ? 'Edit' : 'Create'} User</DialogTitle>
|
||||
</DialogHeader>
|
||||
<ScrollArea className="max-h-[70vh]">
|
||||
<div className="px-2">
|
||||
<Form {...form}>
|
||||
<form action={(e) => form.handleSubmit((data) => {
|
||||
return formAction(data);
|
||||
})()}>
|
||||
<div className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>E-Mail</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<SelectFormField
|
||||
form={form}
|
||||
name="userGroupId"
|
||||
label="Group"
|
||||
formDescription={<>
|
||||
Choose a preconfigured group or create your own in the settings.
|
||||
</>}
|
||||
values={userGroups.map((group) =>
|
||||
[group.id, `${group.name}`])}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="newPassword"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>New Password {user?.id && <>(optional)</>}</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{user?.id && <>Leave empty to keep the old password.</>}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<p className="text-red-500">{state.message}</p>
|
||||
<SubmitButton>Save</SubmitButton>
|
||||
</div>
|
||||
</form>
|
||||
</Form >
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
|
||||
|
||||
|
||||
}
|
||||
389
src/app/settings/users/user-group-edit-overlay.tsx
Normal file
389
src/app/settings/users/user-group-edit-overlay.tsx
Normal file
@@ -0,0 +1,389 @@
|
||||
'use client'
|
||||
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { useFormState } from 'react-dom'
|
||||
import { useEffect, useState } from "react";
|
||||
import { FormUtils } from "@/frontend/utils/form.utilts";
|
||||
import { SubmitButton } from "@/components/custom/submit-button";
|
||||
import { ServerActionResult } from "@/shared/model/server-action-error-return.model"
|
||||
import { toast } from "sonner"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { saveRole } from "./actions"
|
||||
import { RolePermissionEnum } from "@/shared/model/role-extended.model.ts"
|
||||
import { RoleEditModel, roleEditZodModel } from "@/shared/model/role-edit.model"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { ProjectExtendedModel } from "@/shared/model/project-extended.model"
|
||||
import { UserGroupExtended } from "@/shared/model/sim-session.model"
|
||||
|
||||
|
||||
type UiProjectPermission = {
|
||||
projectId: string;
|
||||
createApps: boolean;
|
||||
deleteApps: boolean;
|
||||
writeApps: boolean;
|
||||
readApps: boolean;
|
||||
setPermissionsPerApp: boolean;
|
||||
roleAppPermissions: {
|
||||
appId: string;
|
||||
appName: string;
|
||||
permission?: RolePermissionEnum;
|
||||
}[];
|
||||
};
|
||||
|
||||
export default function RoleEditOverlay({ children, userGroup, projects }: {
|
||||
children: React.ReactNode;
|
||||
userGroup?: UserGroupExtended;
|
||||
projects: ProjectExtendedModel[]
|
||||
}) {
|
||||
|
||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||
|
||||
const [projectPermissions, setProjectPermissions] = useState<UiProjectPermission[]>([]);
|
||||
|
||||
const form = useForm<RoleEditModel>({
|
||||
resolver: zodResolver(roleEditZodModel),
|
||||
defaultValues: userGroup
|
||||
});
|
||||
|
||||
const [state, formAction] = useFormState((state: ServerActionResult<any, any>,
|
||||
payload: RoleEditModel) =>
|
||||
saveRole(state, {
|
||||
...payload,
|
||||
id: userGroup?.id,
|
||||
roleProjectPermissions: projects.map((project) => {
|
||||
const projectPermission = projectPermissions.find((perm) => perm.projectId === project.id);
|
||||
if (!projectPermission) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
projectId: project.id,
|
||||
createApps: projectPermission.createApps,
|
||||
deleteApps: projectPermission.deleteApps,
|
||||
writeApps: projectPermission.writeApps,
|
||||
readApps: projectPermission.readApps,
|
||||
roleAppPermissions: projectPermission.roleAppPermissions.filter(ap => !!ap.permission).map((appPerm) => {
|
||||
return {
|
||||
appId: appPerm.appId,
|
||||
permission: appPerm.permission!,
|
||||
};
|
||||
}),
|
||||
}
|
||||
}).filter((perm) => perm !== undefined),
|
||||
}), FormUtils.getInitialFormState<typeof roleEditZodModel>());
|
||||
|
||||
useEffect(() => {
|
||||
if (state.status === 'success') {
|
||||
form.reset();
|
||||
toast.success('Group saved successfully');
|
||||
setIsOpen(false);
|
||||
}
|
||||
FormUtils.mapValidationErrorsToForm<typeof roleEditZodModel>(state, form);
|
||||
}, [state]);
|
||||
|
||||
useEffect(() => {
|
||||
if (userGroup) {
|
||||
form.reset(userGroup);
|
||||
// Initialize app permissions based on role data
|
||||
const initialPermissions = projects.map(project => {
|
||||
const existingPermission = userGroup.roleProjectPermissions?.find(p => p.projectId === project.id);
|
||||
const roleAppPermissions = project.apps.map(app => ({
|
||||
appId: app.id,
|
||||
appName: app.name,
|
||||
permission: existingPermission?.roleAppPermissions.find(appPerm => appPerm.appId === app.id)?.permission
|
||||
}));
|
||||
const hasNoAppRolePermissionsSet = roleAppPermissions.every(appPerm => !appPerm.permission);
|
||||
return {
|
||||
projectId: project.id,
|
||||
createApps: existingPermission?.createApps || false,
|
||||
deleteApps: existingPermission?.deleteApps || false,
|
||||
writeApps: existingPermission?.writeApps || false,
|
||||
readApps: existingPermission?.readApps || false,
|
||||
setPermissionsPerApp: (existingPermission?.roleAppPermissions.length ?? 0) > 0 || false,
|
||||
roleAppPermissions: hasNoAppRolePermissionsSet ? [] : roleAppPermissions
|
||||
} as UiProjectPermission;
|
||||
});
|
||||
setProjectPermissions(initialPermissions);
|
||||
} else {
|
||||
// Initialize with all apps having no permissions
|
||||
const initialPermissions = projects.map(project => ({
|
||||
projectId: project.id,
|
||||
createApps: false,
|
||||
deleteApps: false,
|
||||
writeApps: false,
|
||||
readApps: false,
|
||||
setPermissionsPerApp: false,
|
||||
roleAppPermissions: []
|
||||
} as UiProjectPermission));
|
||||
setProjectPermissions(initialPermissions);
|
||||
}
|
||||
}, [userGroup, projects, isOpen]);
|
||||
|
||||
|
||||
const handleReadChange = (projectId: string, checked: boolean) => {
|
||||
setProjectPermissions(prev => prev.map(perm => {
|
||||
if (perm.projectId === projectId) {
|
||||
return { ...perm, readApps: checked };
|
||||
}
|
||||
return perm;
|
||||
}));
|
||||
};
|
||||
|
||||
const handleReadWriteChange = (projectId: string, checked: boolean) => {
|
||||
setProjectPermissions(prev => prev.map(perm => {
|
||||
if (perm.projectId === projectId) {
|
||||
return { ...perm, writeApps: checked, readApps: checked ? true : perm.writeApps };
|
||||
}
|
||||
return perm;
|
||||
}));
|
||||
};
|
||||
|
||||
const handleCreateChange = (projectId: string, checked: boolean) => {
|
||||
setProjectPermissions(prev => prev.map(perm => {
|
||||
if (perm.projectId === projectId) {
|
||||
return { ...perm, createApps: checked, readApps: checked ? true : perm.createApps };
|
||||
}
|
||||
return perm;
|
||||
}));
|
||||
};
|
||||
|
||||
const handleDeleteChange = (projectId: string, checked: boolean) => {
|
||||
setProjectPermissions(prev => prev.map(perm => {
|
||||
if (perm.projectId === projectId) {
|
||||
return { ...perm, deleteApps: checked, readApps: checked ? true : perm.deleteApps };
|
||||
}
|
||||
return perm;
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSetPermissionsPerAppChange = (projectId: string, checked: boolean) => {
|
||||
setProjectPermissions(prev => prev.map(perm => {
|
||||
if (perm.projectId === projectId) {
|
||||
const appPermissions = checked ? projects.find(p => p.id === projectId)?.apps.map(app => ({
|
||||
appId: app.id,
|
||||
appName: app.name,
|
||||
permission: undefined
|
||||
})) || [] : [];
|
||||
return {
|
||||
...perm,
|
||||
setPermissionsPerApp: checked,
|
||||
roleAppPermissions: appPermissions,
|
||||
createApps: false,
|
||||
deleteApps: false,
|
||||
writeApps: false,
|
||||
readApps: false
|
||||
};
|
||||
}
|
||||
return perm;
|
||||
}));
|
||||
};
|
||||
|
||||
const handleAppReadChange = (appId: string, checked: boolean) =>
|
||||
setProjectPermissions(prev => prev.map(perm => {
|
||||
if (perm.roleAppPermissions.some(appPerm => appPerm.appId === appId)) {
|
||||
return {
|
||||
...perm,
|
||||
roleAppPermissions: perm.roleAppPermissions.map(appPerm => {
|
||||
if (appPerm.appId === appId) {
|
||||
return { ...appPerm, permission: checked ? RolePermissionEnum.READ : undefined };
|
||||
}
|
||||
return appPerm;
|
||||
})
|
||||
};
|
||||
}
|
||||
return perm;
|
||||
}));
|
||||
|
||||
const handleAppReadWriteChange = (appId: string, checked: boolean) =>
|
||||
setProjectPermissions(prev => prev.map(perm => {
|
||||
if (perm.roleAppPermissions.some(appPerm => appPerm.appId === appId)) {
|
||||
return {
|
||||
...perm,
|
||||
roleAppPermissions: perm.roleAppPermissions.map(appPerm => {
|
||||
if (appPerm.appId === appId) {
|
||||
return { ...appPerm, permission: checked ? RolePermissionEnum.READWRITE : undefined };
|
||||
}
|
||||
return appPerm;
|
||||
})
|
||||
};
|
||||
}
|
||||
return perm;
|
||||
}));
|
||||
|
||||
return (
|
||||
<>
|
||||
<div onClick={() => setIsOpen(true)}>
|
||||
{children}
|
||||
</div>
|
||||
<Dialog open={!!isOpen} onOpenChange={(isOpened) => setIsOpen(isOpened)}>
|
||||
<DialogContent className="sm:max-w-[900px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{userGroup?.id ? 'Edit' : 'Create'} Group</DialogTitle>
|
||||
</DialogHeader>
|
||||
<ScrollArea className="max-h-[70vh]">
|
||||
<div className="px-3">
|
||||
<Form {...form}>
|
||||
<form action={(e) => form.handleSubmit((data) => {
|
||||
return formAction(data);
|
||||
}, console.error)()}>
|
||||
<div className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="canAccessBackups"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="space-y-1 leading-none">
|
||||
<FormLabel>
|
||||
Can access backups
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
If enabled, users can access the backups page and download backups from all apps.
|
||||
</FormDescription>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="pt-3">
|
||||
<h3 className="text-sm font-medium mb-2">App Permissions</h3>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Project</TableHead>
|
||||
<TableHead>Individual Permissions</TableHead>
|
||||
<TableHead>Read Apps</TableHead>
|
||||
<TableHead>Edit/Deploy Apps</TableHead>
|
||||
<TableHead>Create Apps</TableHead>
|
||||
<TableHead>Delete Apps</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{projects.map((project) => {
|
||||
const permission = projectPermissions.find(p => p.projectId === project.id);
|
||||
return (
|
||||
<>
|
||||
<TableRow key={project.id} className={(permission?.roleAppPermissions.length ?? 0) === 0 ? 'border-b-gray-400' : ''} >
|
||||
<TableCell className="font-semibold">{project.name}</TableCell>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
id={`delete-${project.id}`}
|
||||
checked={permission?.setPermissionsPerApp || false}
|
||||
onCheckedChange={(checked) => handleSetPermissionsPerAppChange(project.id, !!checked)}
|
||||
/>
|
||||
</TableCell>
|
||||
{permission?.setPermissionsPerApp ?
|
||||
<TableHead>App</TableHead>
|
||||
: <TableCell>
|
||||
<Checkbox
|
||||
id={`read-${project.id}`}
|
||||
disabled={permission?.writeApps || permission?.deleteApps || permission?.createApps}
|
||||
checked={permission?.readApps || false}
|
||||
onCheckedChange={(checked) => handleReadChange(project.id, !!checked)}
|
||||
/>
|
||||
</TableCell>}
|
||||
<TableCell>
|
||||
{!permission?.setPermissionsPerApp &&
|
||||
<Checkbox
|
||||
id={`write-${project.id}`}
|
||||
checked={permission?.writeApps || false}
|
||||
onCheckedChange={(checked) => handleReadWriteChange(project.id, !!checked)}
|
||||
/>}
|
||||
</TableCell>
|
||||
{permission?.setPermissionsPerApp ?
|
||||
<TableHead>Read</TableHead>
|
||||
: <TableCell>
|
||||
<Checkbox
|
||||
id={`create-${project.id}`}
|
||||
checked={permission?.createApps || false}
|
||||
onCheckedChange={(checked) => handleCreateChange(project.id, !!checked)}
|
||||
/>
|
||||
</TableCell>}
|
||||
{permission?.setPermissionsPerApp ?
|
||||
<TableHead>Read, Write & Deploy</TableHead>
|
||||
: <TableCell>
|
||||
<Checkbox
|
||||
id={`delete-${project.id}`}
|
||||
checked={permission?.deleteApps || false}
|
||||
onCheckedChange={(checked) => handleDeleteChange(project.id, !!checked)}
|
||||
/>
|
||||
</TableCell>}
|
||||
</TableRow>
|
||||
|
||||
|
||||
{(permission?.roleAppPermissions.length ?? 0) > 0 &&
|
||||
<>
|
||||
{permission?.roleAppPermissions.map((roleAppPermission, index) =>
|
||||
|
||||
<TableRow key={roleAppPermission.appId} className={permission.roleAppPermissions.length - 1 === index ? 'border-b-gray-400' : ''}>
|
||||
<TableCell></TableCell>
|
||||
<TableCell></TableCell>
|
||||
<TableCell colSpan={2}>{roleAppPermission.appName}</TableCell>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
id={`app-read-${roleAppPermission.appId}`}
|
||||
checked={roleAppPermission.permission === RolePermissionEnum.READ}
|
||||
onCheckedChange={(checked) => handleAppReadChange(roleAppPermission.appId, !!checked)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
id={`app-readwrite-${roleAppPermission.appId}`}
|
||||
checked={roleAppPermission.permission === RolePermissionEnum.READWRITE}
|
||||
onCheckedChange={(checked) => handleAppReadWriteChange(roleAppPermission.appId, !!checked)}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
)}
|
||||
</>}
|
||||
</>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<p className="text-red-500">{state.message}</p>
|
||||
<SubmitButton>Save</SubmitButton>
|
||||
</div>
|
||||
</form>
|
||||
</Form >
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog >
|
||||
</>
|
||||
)
|
||||
}
|
||||
61
src/app/settings/users/user-groups-table.tsx
Normal file
61
src/app/settings/users/user-groups-table.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { EditIcon, Plus, TrashIcon } from "lucide-react";
|
||||
import { Toast } from "@/frontend/utils/toast.utils";
|
||||
import { useConfirmDialog } from "@/frontend/states/zustand.states";
|
||||
import React from "react";
|
||||
import { SimpleDataTable } from "@/components/custom/simple-data-table";
|
||||
import { formatDateTime } from "@/frontend/utils/format.utils";
|
||||
import { deleteRole } from "./actions";
|
||||
import { adminRoleName } from "@/shared/model/role-extended.model.ts";
|
||||
import RoleEditOverlay from "./user-group-edit-overlay";
|
||||
import { ProjectExtendedModel } from "@/shared/model/project-extended.model";
|
||||
import { UserGroupExtended } from "@/shared/model/sim-session.model";
|
||||
|
||||
export default function UserGroupsTable({ userGroups, projects }: {
|
||||
userGroups: UserGroupExtended[];
|
||||
projects: ProjectExtendedModel[];
|
||||
}) {
|
||||
|
||||
const { openConfirmDialog: openDialog } = useConfirmDialog();
|
||||
|
||||
const asyncDeleteItem = async (id: string) => {
|
||||
const confirm = await openDialog({
|
||||
title: "Delete Group",
|
||||
description: "Do you really want to delete this group? Users with this group will be assigned to no role afterwards. They will not be able to use QuickStack until you reassign a new group to them.",
|
||||
okButton: "Delete",
|
||||
});
|
||||
if (confirm) {
|
||||
await Toast.fromAction(() => deleteRole(id), 'Deleting Group...', 'Group deleted successfully');
|
||||
}
|
||||
};
|
||||
|
||||
return <>
|
||||
<SimpleDataTable columns={[
|
||||
['id', 'ID', false],
|
||||
['name', 'Name', true],
|
||||
["createdAt", "Created At", true, (item) => formatDateTime((item as any).createdAt)],
|
||||
["updatedAt", "Updated At", false, (item) => formatDateTime((item as any).updatedAt)],
|
||||
]}
|
||||
data={userGroups}
|
||||
actionCol={(item) =>
|
||||
<>
|
||||
<div className="flex">
|
||||
{item.name !== adminRoleName && <>
|
||||
<div className="flex-1"></div>
|
||||
<RoleEditOverlay projects={projects} userGroup={item} >
|
||||
<Button variant="ghost"><EditIcon /></Button>
|
||||
</RoleEditOverlay>
|
||||
<Button variant="ghost" onClick={() => asyncDeleteItem(item.id)}>
|
||||
<TrashIcon />
|
||||
</Button>
|
||||
</>}
|
||||
</div>
|
||||
</>}
|
||||
/>
|
||||
<RoleEditOverlay projects={projects} >
|
||||
<Button variant="secondary"><Plus /> Create Group</Button>
|
||||
</RoleEditOverlay>
|
||||
</>;
|
||||
}
|
||||
83
src/app/settings/users/users-table-bulk-role-assignment.tsx
Normal file
83
src/app/settings/users/users-table-bulk-role-assignment.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { UserExtended } from "@/shared/model/user-extended.model";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Toast } from "@/frontend/utils/toast.utils";
|
||||
import { assignRoleToUsers } from "./actions";
|
||||
import { UserGroupExtended } from "@/shared/model/sim-session.model";
|
||||
|
||||
interface UsersBulkRoleAssignmentProps {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
selectedUsers: UserExtended[];
|
||||
userGroups: UserGroupExtended[];
|
||||
}
|
||||
|
||||
export default function UsersBulkRoleAssignment({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
selectedUsers,
|
||||
userGroups
|
||||
}: UsersBulkRoleAssignmentProps) {
|
||||
const [selectedGroup, setSelectedGroup] = useState<string>("");
|
||||
|
||||
const handleAssignGroup = async () => {
|
||||
if (!selectedGroup) {
|
||||
toast.error("Please select a group");
|
||||
return;
|
||||
}
|
||||
|
||||
await Toast.fromAction(() => assignRoleToUsers(selectedUsers.map(u => u.id), selectedGroup), `Group
|
||||
assigned to ${selectedUsers.length} user(s)`, 'Assigning Group...');
|
||||
onOpenChange(false);
|
||||
setSelectedGroup("");
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Assign Group</DialogTitle>
|
||||
<DialogDescription>
|
||||
Select a Group to assign to {selectedUsers.length} selected user(s).
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Select onValueChange={setSelectedGroup} value={selectedGroup}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a group" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{userGroups.map((role) => (
|
||||
<SelectItem key={role.id} value={role.id}>
|
||||
{role.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleAssignGroup}>Assign</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
132
src/app/settings/users/users-table.tsx
Normal file
132
src/app/settings/users/users-table.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ArrowDown, ChevronDown, EditIcon, Plus, Trash2, TrashIcon, UserPlus } from "lucide-react";
|
||||
import { Toast } from "@/frontend/utils/toast.utils";
|
||||
import { useConfirmDialog } from "@/frontend/states/zustand.states";
|
||||
import React from "react";
|
||||
import { SimpleDataTable } from "@/components/custom/simple-data-table";
|
||||
import { formatDateTime } from "@/frontend/utils/format.utils";
|
||||
import { UserExtended } from "@/shared/model/user-extended.model";
|
||||
import UserEditOverlay from "./user-edit-overlay";
|
||||
import { deleteUser } from "./actions";
|
||||
import { UserGroupExtended, UserSession } from "@/shared/model/sim-session.model";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { toast } from "sonner";
|
||||
import UsersBulkRoleAssignment from "./users-table-bulk-role-assignment";
|
||||
import { Actions } from "@/frontend/utils/nextjs-actions.utils";
|
||||
|
||||
export default function UsersTable({ users, userGroups, session }: {
|
||||
users: UserExtended[];
|
||||
userGroups: UserGroupExtended[];
|
||||
session: UserSession;
|
||||
}) {
|
||||
|
||||
const { openConfirmDialog: openDialog } = useConfirmDialog();
|
||||
const [selectedUsers, setSelectedUsers] = React.useState<UserExtended[]>([]);
|
||||
const [isRoleDialogOpen, setIsRoleDialogOpen] = React.useState(false);
|
||||
|
||||
const asyncDeleteItem = async (id: string) => {
|
||||
const confirm = await openDialog({
|
||||
title: "Delete User",
|
||||
description: "Do you really want to delete this user?",
|
||||
okButton: "Delete",
|
||||
});
|
||||
if (confirm) {
|
||||
await Toast.fromAction(() => deleteUser(id), 'Deleting User...', 'User deleted successfully');
|
||||
}
|
||||
};
|
||||
|
||||
const handleBulkDelete = async () => {
|
||||
// Filter out admin users from selected users
|
||||
const deletableUsers = selectedUsers.filter(user => session.email !== user.email);
|
||||
|
||||
if (deletableUsers.length === 0) {
|
||||
toast.error("No deletable users selected (admins cannot be deleted)");
|
||||
return;
|
||||
}
|
||||
|
||||
const confirm = await openDialog({
|
||||
title: "Delete Selected Users",
|
||||
description: `Do you really want to delete ${deletableUsers.length} user(s)?`,
|
||||
okButton: "Delete",
|
||||
});
|
||||
|
||||
if (confirm) {
|
||||
try {
|
||||
// Delete users one by one
|
||||
for (const user of deletableUsers) {
|
||||
await Actions.run(() => deleteUser(user.id));
|
||||
}
|
||||
toast.success(`Successfully deleted ${deletableUsers.length} user(s)`);
|
||||
} catch (error) {
|
||||
toast.error("Error deleting users");
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return <>
|
||||
<SimpleDataTable columns={[
|
||||
['id', 'ID', false],
|
||||
['email', 'Mail', true],
|
||||
['role.name', 'Role', true],
|
||||
["createdAt", "Created At", true, (item) => formatDateTime(item.createdAt)],
|
||||
["updatedAt", "Updated At", false, (item) => formatDateTime(item.updatedAt)],
|
||||
]}
|
||||
data={users}
|
||||
showSelectCheckbox={true}
|
||||
onRowSelectionUpdate={setSelectedUsers}
|
||||
columnFilters={userGroups.map((userGroup) => ({
|
||||
accessorKey: 'role.name',
|
||||
filterLabel: userGroup.name,
|
||||
filterFunction: (item: UserExtended) => item.userGroupId === userGroup.id,
|
||||
}))}
|
||||
actionCol={(item) =>
|
||||
<>
|
||||
<div className="flex">
|
||||
<div className="flex-1"></div>
|
||||
{session.email !== item.email && <><UserEditOverlay user={item} userGroups={userGroups}>
|
||||
<Button variant="ghost"><EditIcon /></Button>
|
||||
</UserEditOverlay>
|
||||
<Button variant="ghost" onClick={() => asyncDeleteItem(item.id)}>
|
||||
<TrashIcon />
|
||||
</Button>
|
||||
</>}
|
||||
</div>
|
||||
</>}
|
||||
/>
|
||||
<div className="flex gap-4">
|
||||
<UserEditOverlay userGroups={userGroups}>
|
||||
<Button variant="secondary"><Plus /> Create User</Button>
|
||||
</UserEditOverlay>
|
||||
{selectedUsers.length > 0 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline"> Actions <ChevronDown /></Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem onClick={() => setIsRoleDialogOpen(true)}>
|
||||
<UserPlus /> Assign Group
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleBulkDelete}>
|
||||
<Trash2 /> Delete Selected
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<UsersBulkRoleAssignment
|
||||
isOpen={isRoleDialogOpen}
|
||||
onOpenChange={setIsRoleDialogOpen}
|
||||
selectedUsers={selectedUsers}
|
||||
userGroups={userGroups}
|
||||
/>
|
||||
</>;
|
||||
}
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
SidebarMenuAction,
|
||||
useSidebar
|
||||
} from "@/components/ui/sidebar"
|
||||
import { BookOpen, Boxes, ChartNoAxesCombined, ChevronDown, ChevronRight, ChevronUp, Dot, FolderClosed, History, Info, Plus, Server, Settings, Settings2, User } from "lucide-react"
|
||||
import { BookOpen, Boxes, ChartNoAxesCombined, ChevronDown, ChevronRight, ChevronUp, Dot, FolderClosed, History, Info, Plus, Server, Settings, Settings2, User, User2 } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { EditProjectDialog } from "./projects/edit-project-dialog"
|
||||
import { SidebarLogoutButton } from "./sidebar-logout-button"
|
||||
@@ -31,6 +31,7 @@ import { usePathname } from "next/navigation"
|
||||
import { useRouter } from "next/router"
|
||||
import { useEffect, useState } from "react"
|
||||
import QuickStackLogo from "@/components/custom/quickstack-logo"
|
||||
import { UserGroupUtils } from "@/shared/utils/role.utils"
|
||||
|
||||
|
||||
const settingsMenu = [
|
||||
@@ -39,25 +40,32 @@ const settingsMenu = [
|
||||
url: "/settings/profile",
|
||||
icon: User,
|
||||
},
|
||||
{
|
||||
title: "Users & Groups",
|
||||
url: "/settings/users",
|
||||
icon: User2,
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
title: "S3 Targets",
|
||||
url: "/settings/s3-targets",
|
||||
icon: Settings,
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
title: "QuickStack Settings",
|
||||
url: "/settings/server",
|
||||
icon: Settings,
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
title: "Cluster",
|
||||
url: "/settings/cluster",
|
||||
icon: Server,
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
title: "Maintenance",
|
||||
url: "/settings/maintenance",
|
||||
icon: Settings,
|
||||
adminOnly: true,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -113,7 +121,7 @@ export function SidebarCient({
|
||||
<SidebarMenuButton size="lg"
|
||||
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground">
|
||||
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-qs-500 text-sidebar-primary-foreground">
|
||||
<QuickStackLogo className="size-5" color="light-all" />
|
||||
<QuickStackLogo className="size-5" color="light-all" />
|
||||
</div>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight my-4">
|
||||
<span className="truncate font-semibold">QuickStack</span>
|
||||
@@ -157,11 +165,11 @@ export function SidebarCient({
|
||||
<span>Projects</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
<EditProjectDialog>
|
||||
{UserGroupUtils.isAdmin(session) && <EditProjectDialog>
|
||||
<SidebarMenuAction>
|
||||
<Plus />
|
||||
</SidebarMenuAction>
|
||||
</EditProjectDialog>
|
||||
</EditProjectDialog>}
|
||||
<SidebarMenu>
|
||||
{projects.map((item) => (
|
||||
<DropdownMenu key={item.id}>
|
||||
@@ -226,12 +234,12 @@ export function SidebarCient({
|
||||
</SidebarGroup>
|
||||
|
||||
|
||||
<SidebarGroup>
|
||||
{UserGroupUtils.sessionHasAccessToBackups(session) && <SidebarGroup>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton asChild tooltip={{
|
||||
children: 'Monitoring',
|
||||
children: 'Backups',
|
||||
hidden: open,
|
||||
}}
|
||||
isActive={path.startsWith('/backups')}>
|
||||
@@ -243,7 +251,7 @@ export function SidebarCient({
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarGroup>}
|
||||
|
||||
|
||||
<SidebarGroup>
|
||||
@@ -260,16 +268,16 @@ export function SidebarCient({
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
<SidebarMenuSub>
|
||||
{settingsMenu.map((item) => (
|
||||
<SidebarMenuSubItem key={item.title}>
|
||||
<SidebarMenuButton asChild>
|
||||
<Link href={item.url}>
|
||||
|
||||
<span>{item.title}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuSubItem>
|
||||
))}
|
||||
{(UserGroupUtils.isAdmin(session) ? settingsMenu :
|
||||
settingsMenu.filter(x => !x.adminOnly)).map((item) => (
|
||||
<SidebarMenuSubItem key={item.title}>
|
||||
<SidebarMenuButton asChild>
|
||||
<Link href={item.url}>
|
||||
<span>{item.title}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuSubItem>
|
||||
))}
|
||||
</SidebarMenuSub>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import projectService from "@/server/services/project.service"
|
||||
import { getUserSession } from "@/server/utils/action-wrapper.utils"
|
||||
import { SidebarCient } from "./sidebar-client"
|
||||
import { UserGroupUtils } from "@/shared/utils/role.utils";
|
||||
|
||||
export async function AppSidebar() {
|
||||
|
||||
@@ -12,5 +13,11 @@ export async function AppSidebar() {
|
||||
|
||||
const projects = await projectService.getAllProjects();
|
||||
|
||||
return <SidebarCient projects={projects} session={session} />
|
||||
const relevantProjectsForUser = projects.filter((project) =>
|
||||
UserGroupUtils.sessionHasReadAccessToProject(session, project.id));
|
||||
for (const project of relevantProjectsForUser) {
|
||||
project.apps = project.apps.filter((app) => UserGroupUtils.sessionHasReadAccessForApp(session, app.id));
|
||||
}
|
||||
|
||||
return <SidebarCient projects={relevantProjectsForUser} session={session} />
|
||||
}
|
||||
|
||||
@@ -13,7 +13,9 @@ import {
|
||||
VisibilityState,
|
||||
getSortedRowModel,
|
||||
filterFns,
|
||||
FilterFnOption
|
||||
FilterFnOption,
|
||||
PaginationState,
|
||||
TableState
|
||||
} from "@tanstack/react-table"
|
||||
|
||||
import {
|
||||
@@ -45,53 +47,80 @@ export function DefaultDataTable<TData, TValue>({
|
||||
data,
|
||||
globalFilterFn,
|
||||
hideSearchBar = false,
|
||||
onColumnVisabilityUpdate
|
||||
initialTableState,
|
||||
onRowSelectionUpdate,
|
||||
onTableStateChanged
|
||||
}: DataTableProps<TData, TValue> & {
|
||||
hideSearchBar?: boolean,
|
||||
onColumnVisabilityUpdate?: (visabilityConfig: [string, boolean][]) => void
|
||||
globalFilterFn?: FilterFnOption<any> | undefined
|
||||
hideSearchBar?: boolean;
|
||||
onRowSelectionUpdate?: (selectedItems: TData[]) => void;
|
||||
onTableStateChanged?: (state: Partial<TableState>) => void;
|
||||
initialTableState?: Partial<TableState>;
|
||||
globalFilterFn?: FilterFnOption<any> | undefined;
|
||||
}) {
|
||||
const [sorting, setSorting] = React.useState<SortingState>([]);
|
||||
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
|
||||
[]
|
||||
);
|
||||
const [globalFilter, setGlobalFilter] = React.useState<any>([])
|
||||
const [sorting, setSorting] = React.useState<SortingState>(initialTableState?.sorting ?? []);
|
||||
const [globalFilter, setGlobalFilter] = React.useState<any>(initialTableState?.globalFilter ?? []);
|
||||
const [pagination, setPagination] = React.useState<PaginationState>(initialTableState?.pagination ?? {
|
||||
pageSize: 10,
|
||||
pageIndex: 0
|
||||
});
|
||||
|
||||
const initialVisabilityState = columns.filter(col => (col as any).isVisible === false).reduce((acc, col) => {
|
||||
acc[(col as any).accessorKey] = false;
|
||||
const [rowSelection, setRowSelection] = React.useState<any>({});
|
||||
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([]);
|
||||
const initialVisabilityState = columns.reduce((acc, col) => {
|
||||
const accessorKey = (col as any).accessorKey;
|
||||
if (!accessorKey) {
|
||||
return acc;
|
||||
}
|
||||
const valueOfInitialState = initialTableState?.columnVisibility?.[accessorKey];
|
||||
acc[accessorKey] = valueOfInitialState ?? !!(col as any).isVisible;
|
||||
return acc;
|
||||
}, {} as VisibilityState);
|
||||
|
||||
const [columnVisibility, setColumnVisibility] =
|
||||
React.useState<VisibilityState>(initialVisabilityState);
|
||||
const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>(initialVisabilityState);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (onColumnVisabilityUpdate) {
|
||||
onColumnVisabilityUpdate(table.getAllColumns().filter(x => (x.columnDef as any).accessorKey).map(x => [(x.columnDef as any).accessorKey, x.getIsVisible()]));
|
||||
if (onRowSelectionUpdate) {
|
||||
const indexes = Object.keys(rowSelection).filter(key => Boolean(rowSelection[key] as boolean)).map(key => parseInt(key));
|
||||
// the core row model contains all unfilteres rows, the indexes wich are given by the rowSelection are the indexes of the core row model
|
||||
const values = table.getCoreRowModel().rows.map(row => row.original);
|
||||
onRowSelectionUpdate(values.filter((_, index) => indexes.includes(index)));
|
||||
}
|
||||
}, [columnVisibility]);
|
||||
|
||||
}, [rowSelection]);
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
onSortingChange: setSorting,
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
onSortingChange: setSorting,
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onPaginationChange: setPagination,
|
||||
onGlobalFilterChange: setGlobalFilter,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
enableGlobalFilter: true,
|
||||
globalFilterFn: globalFilterFn ?? filterFns.includesString,
|
||||
state: {
|
||||
sorting,
|
||||
columnFilters,
|
||||
columnVisibility,
|
||||
globalFilter
|
||||
globalFilter,
|
||||
rowSelection,
|
||||
pagination
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (onTableStateChanged) {
|
||||
onTableStateChanged({
|
||||
sorting,
|
||||
pagination,
|
||||
globalFilter,
|
||||
columnVisibility
|
||||
})
|
||||
}
|
||||
}, [sorting, columnVisibility, globalFilter, pagination]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -152,7 +181,7 @@ export function DefaultDataTable<TData, TValue>({
|
||||
</Table>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<DataTablePagination table={table} />
|
||||
<DataTablePagination table={table}/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
"use client"
|
||||
|
||||
import { ColumnDef, Row } from "@tanstack/react-table"
|
||||
import { ColumnDef, Row, TableState } from "@tanstack/react-table"
|
||||
import { DataTableColumnHeader } from "@/components/ui/column-header"
|
||||
import { ReactNode, useEffect, useState } from "react"
|
||||
import { DefaultDataTable } from "./default-data-table"
|
||||
import { usePathname, useRouter } from "next/navigation"
|
||||
import FullLoadingSpinner from "../ui/full-loading-spinnter"
|
||||
|
||||
|
||||
import { ReactNodeUtils } from "@/shared/utils/react-node.utils"
|
||||
import { Checkbox } from "../ui/checkbox"
|
||||
|
||||
export function SimpleDataTable<TData>({
|
||||
tableIdentifier,
|
||||
@@ -17,48 +17,44 @@ export function SimpleDataTable<TData>({
|
||||
onItemClick,
|
||||
onItemClickLink,
|
||||
hideSearchBar = false,
|
||||
showSelectCheckbox = false,
|
||||
onRowSelectionUpdate,
|
||||
columnFilters,
|
||||
}: {
|
||||
tableIdentifier?: string,
|
||||
columns: ([string, string, boolean, (item: TData) => ReactNode] | [string, string, boolean])[],
|
||||
data: TData[],
|
||||
hideSearchBar?: boolean,
|
||||
showSelectCheckbox?: boolean,
|
||||
onItemClick?: (selectedItem: TData) => void,
|
||||
onItemClickLink?: (selectedItem: TData) => string,
|
||||
actionCol?: (selectedItem: TData) => ReactNode
|
||||
actionCol?: (selectedItem: TData) => ReactNode,
|
||||
onRowSelectionUpdate?: (selectedItems: TData[]) => void
|
||||
columnFilters?: {
|
||||
accessorKey: string,
|
||||
filterLabel: string,
|
||||
filterFunction: (item: TData) => boolean
|
||||
}[]
|
||||
}) {
|
||||
|
||||
const router = useRouter();
|
||||
const pathName = usePathname();
|
||||
const [columnsWithVisability, setColumnsWithVisability] = useState<(([string, string, boolean, (item: TData) => ReactNode] | [string, string, boolean])[]) | undefined>(undefined);
|
||||
const [columnInputData, setColumnInputData] = useState<TData[] | undefined>(undefined);
|
||||
const [initialTableState, setInitialTableState] = useState<Partial<TableState> | undefined>(undefined);
|
||||
|
||||
const setUserVisabilityForColumns = function <TData>(columns: ([string, string, boolean, (item: TData) => ReactNode] | [string, string, boolean])[]) {
|
||||
if (!columns) {
|
||||
return;
|
||||
}
|
||||
const configFromLocalstorage = window.localStorage.getItem(`tableConfig-${tableIdentifier ?? pathName}`) || undefined;
|
||||
let parsedConfig: [string, boolean][] = [];
|
||||
if (!!configFromLocalstorage) {
|
||||
parsedConfig = JSON.parse(configFromLocalstorage);
|
||||
}
|
||||
for (const col of columns) {
|
||||
const [accessorKey, header, isVisible] = col;
|
||||
const storedConfig = parsedConfig.find(([key]) => key === accessorKey);
|
||||
if (storedConfig) {
|
||||
col[2] = storedConfig[1];
|
||||
}
|
||||
}
|
||||
const onTableStateChange = (newState: Partial<TableState>) => {
|
||||
const tableState = {
|
||||
columnVisibility: newState.columnVisibility,
|
||||
sorting: newState.sorting,
|
||||
paginationPageSize: newState.pagination?.pageSize
|
||||
};
|
||||
window.localStorage.setItem(`table-${tableIdentifier ?? pathName}`, JSON.stringify(tableState));
|
||||
window.sessionStorage.setItem(`table-${tableIdentifier ?? pathName}`, JSON.stringify({
|
||||
globalFilter: newState.globalFilter,
|
||||
paginationPageIndex: newState.pagination?.pageIndex
|
||||
}));
|
||||
}
|
||||
|
||||
const updateVisabilityConfig = (visabilityConfig: [string, boolean][]) => {
|
||||
window.localStorage.setItem(`tableConfig-${tableIdentifier ?? pathName}`, JSON.stringify(visabilityConfig));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setUserVisabilityForColumns(columns);
|
||||
setColumnsWithVisability(columns);
|
||||
}, [columns]);
|
||||
|
||||
useEffect(() => {
|
||||
const outData = data.map((item) => {
|
||||
for (const [accessorKey, headerName, isVisible, customRowDefinition] of columns) {
|
||||
@@ -70,9 +66,25 @@ export function SimpleDataTable<TData>({
|
||||
return item;
|
||||
});
|
||||
setColumnInputData(outData);
|
||||
|
||||
const configJsonFromLocalstorage = window.localStorage.getItem(`table-${tableIdentifier ?? pathName}`);
|
||||
const configJsonFromSessionstorage = window.sessionStorage.getItem(`table-${tableIdentifier ?? pathName}`);
|
||||
const configFromLocalStorage = JSON.parse(configJsonFromLocalstorage ?? '{}');
|
||||
const configFromSessionStorage = JSON.parse(configJsonFromSessionstorage ?? '{}');
|
||||
const mergedConfig = {
|
||||
columnVisibility: configFromLocalStorage.columnVisibility,
|
||||
sorting: configFromLocalStorage.sorting,
|
||||
globalFilter: configFromSessionStorage.globalFilter,
|
||||
pagination: {
|
||||
pageSize: configFromLocalStorage.paginationPageSize ?? 10,
|
||||
pageIndex: configFromSessionStorage.paginationPageIndex ?? 0
|
||||
}
|
||||
};
|
||||
setInitialTableState(mergedConfig);
|
||||
|
||||
}, [data, columns]);
|
||||
|
||||
if (!columnsWithVisability || !columnInputData) {
|
||||
if (!columnInputData || !initialTableState) {
|
||||
return <FullLoadingSpinner />;
|
||||
}
|
||||
|
||||
@@ -86,10 +98,10 @@ export function SimpleDataTable<TData>({
|
||||
const columnDefinitionForFilter = columns.find(col => col[0] === headerName);
|
||||
if (columnDefinitionForFilter && columnDefinitionForFilter[3]) {
|
||||
const columnValue = columnDefinitionForFilter[3](row.original);
|
||||
if (typeof columnValue === 'string') {
|
||||
return columnValue.toLowerCase();
|
||||
const text = ReactNodeUtils.getTextFromReactElement(columnValue);
|
||||
if (typeof text === 'string') {
|
||||
return text.toLowerCase();
|
||||
}
|
||||
return '';
|
||||
}
|
||||
// use default column value for filtering
|
||||
return String(cell.getValue() ?? '').toLowerCase();
|
||||
@@ -97,23 +109,40 @@ export function SimpleDataTable<TData>({
|
||||
return allCellValues.join(' ').includes(searchTerm.toLowerCase());
|
||||
};
|
||||
|
||||
const indexOfFirstVisibleColumn = columnsWithVisability.findIndex(([_, __, isVisible]) => isVisible);
|
||||
const dataColumns = columnsWithVisability.map(([accessorKey, header, isVisible, customRowDefinition], columnIndex) => {
|
||||
const indexOfFirstVisibleColumn = columns.findIndex(([_, __, isVisible]) => isVisible);
|
||||
const dataColumns = columns.map(([accessorKey, header, isVisible, customRowDefinition], columnIndex) => {
|
||||
|
||||
const columnFiltersForThisColumn = columnFilters?.filter(filter => filter.accessorKey === accessorKey);
|
||||
|
||||
const dataCol = {
|
||||
accessorKey,
|
||||
isVisible,
|
||||
headerName: header,
|
||||
filterFn: (row, searchTerm) => {
|
||||
const columnValue = ((customRowDefinition ? customRowDefinition(row.original) : (row.original as any)[accessorKey] as unknown as string) ?? '');
|
||||
console.log(columnValue)
|
||||
if (typeof columnValue === 'string') {
|
||||
return columnValue.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
filterFn: (row, columnName, searchTerm) => {
|
||||
if (searchTerm === undefined || searchTerm === null || searchTerm === '') {
|
||||
return true;
|
||||
}
|
||||
if (columnFiltersForThisColumn && columnFiltersForThisColumn.length > 0) {
|
||||
if (Array.isArray(searchTerm)) {
|
||||
return columnFiltersForThisColumn
|
||||
.filter(filter => searchTerm.includes(`${filter.accessorKey}_${filter.filterLabel}`))
|
||||
.some(filter => filter.filterFunction(row.original));
|
||||
}
|
||||
return columnFiltersForThisColumn.some(filter => filter.filterFunction(row.original));
|
||||
} else {
|
||||
let columnValue = customRowDefinition
|
||||
? customRowDefinition(row.original)
|
||||
: (row.original as any)[accessorKey] as unknown as string | ReactNode;
|
||||
|
||||
columnValue = ReactNodeUtils.getTextFromReactElement((columnValue ?? ''));
|
||||
if (typeof columnValue === 'string') {
|
||||
return columnValue.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
header: ({ column }: { column: any }) => header && (
|
||||
<DataTableColumnHeader column={column} title={header} />
|
||||
<DataTableColumnHeader disableSorting={!!customRowDefinition} column={column} title={header} filterOptions={columnFiltersForThisColumn} />
|
||||
)
|
||||
} as ColumnDef<TData>;
|
||||
|
||||
@@ -146,7 +175,31 @@ export function SimpleDataTable<TData>({
|
||||
return dataCol;
|
||||
});
|
||||
|
||||
const selectableColumns: ColumnDef<TData>[] = showSelectCheckbox ? [{
|
||||
id: "select",
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
checked={
|
||||
table.getIsAllPageRowsSelected() ||
|
||||
(table.getIsSomePageRowsSelected() && "indeterminate")
|
||||
}
|
||||
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||
aria-label="Select all"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
aria-label="Select row"
|
||||
/>
|
||||
),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
}] : [];
|
||||
|
||||
const finalCols: ColumnDef<TData>[] = [
|
||||
...selectableColumns,
|
||||
...dataColumns
|
||||
];
|
||||
|
||||
@@ -160,5 +213,12 @@ export function SimpleDataTable<TData>({
|
||||
});
|
||||
}
|
||||
|
||||
return <DefaultDataTable globalFilterFn={globalFilterFn} columns={finalCols} data={columnInputData} hideSearchBar={hideSearchBar} onColumnVisabilityUpdate={updateVisabilityConfig} />
|
||||
return <DefaultDataTable
|
||||
initialTableState={initialTableState}
|
||||
onTableStateChanged={onTableStateChange}
|
||||
globalFilterFn={globalFilterFn}
|
||||
columns={finalCols}
|
||||
data={columnInputData}
|
||||
hideSearchBar={hideSearchBar}
|
||||
onRowSelectionUpdate={onRowSelectionUpdate} />
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { cn } from "@/frontend/utils/utils"
|
||||
import { Button } from "./button"
|
||||
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator } from "./dropdown-menu"
|
||||
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuLabel } from "./dropdown-menu"
|
||||
import {
|
||||
ArrowDownIcon,
|
||||
ArrowUpIcon,
|
||||
@@ -8,57 +8,128 @@ import {
|
||||
EyeNoneIcon,
|
||||
} from "@radix-ui/react-icons"
|
||||
import { Column } from "@tanstack/react-table"
|
||||
import { useState } from "react"
|
||||
import { FilterIcon, FilterX, Trash2 } from "lucide-react"
|
||||
|
||||
interface DataTableColumnHeaderProps<TData, TValue>
|
||||
extends React.HTMLAttributes<HTMLDivElement> {
|
||||
column: Column<TData, TValue>
|
||||
title: string
|
||||
interface DataTableColumnHeaderProps<TData, TValue>
|
||||
extends React.HTMLAttributes<HTMLDivElement> {
|
||||
column: Column<TData, TValue>
|
||||
title: string
|
||||
filterOptions?: {
|
||||
accessorKey: string,
|
||||
filterLabel: string
|
||||
}[],
|
||||
disableSorting?: boolean
|
||||
}
|
||||
|
||||
export function DataTableColumnHeader<TData, TValue>({
|
||||
column,
|
||||
title,
|
||||
className,
|
||||
column,
|
||||
title,
|
||||
className,
|
||||
filterOptions = [],
|
||||
disableSorting = false,
|
||||
}: DataTableColumnHeaderProps<TData, TValue>) {
|
||||
if (!column.getCanSort()) {
|
||||
return <div className={cn(className)}>{title}</div>
|
||||
}
|
||||
const [tempFilters, setTempFilters] = useState<string[]>([])
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center space-x-2", className)}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="-ml-3 h-8 data-[state=open]:bg-accent"
|
||||
>
|
||||
<span>{title}</span>
|
||||
{column.getIsSorted() === "desc" ? (
|
||||
<ArrowDownIcon className="ml-2 h-4 w-4" />
|
||||
) : column.getIsSorted() === "asc" ? (
|
||||
<ArrowUpIcon className="ml-2 h-4 w-4" />
|
||||
) : (
|
||||
<CaretSortIcon className="ml-2 h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem onClick={() => column.toggleSorting(false)}>
|
||||
<ArrowUpIcon className="mr-2 h-3.5 w-3.5 text-muted-foreground/70" />
|
||||
Asc
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => column.toggleSorting(true)}>
|
||||
<ArrowDownIcon className="mr-2 h-3.5 w-3.5 text-muted-foreground/70" />
|
||||
Desc
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => column.toggleVisibility(false)}>
|
||||
<EyeNoneIcon className="mr-2 h-3.5 w-3.5 text-muted-foreground/70" />
|
||||
Hide
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const handleFilterToggle = (option: string, column: Column<TData, TValue>) => {
|
||||
let newFilters: string[] | undefined = tempFilters.includes(option)
|
||||
? tempFilters.filter(x => x !== option)
|
||||
: [...tempFilters, option]
|
||||
|
||||
if (newFilters.length === 0) {
|
||||
newFilters = undefined;
|
||||
}
|
||||
setTempFilters(newFilters ?? []);
|
||||
column.setFilterValue(newFilters);
|
||||
}
|
||||
|
||||
const clearFilters = () => {
|
||||
setTempFilters([]);
|
||||
column.setFilterValue(undefined);
|
||||
}
|
||||
|
||||
if (!column.getCanSort() && filterOptions.length === 0) {
|
||||
return <div className={cn(className)}>{title}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center space-x-0.5", className)}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="-ml-3 h-8 data-[state=open]:bg-accent"
|
||||
>
|
||||
<span>{title}</span>
|
||||
{!disableSorting && <>
|
||||
{
|
||||
column.getIsSorted() === "desc" ? (
|
||||
<ArrowDownIcon className="ml-2 h-4 w-4" />
|
||||
) : column.getIsSorted() === "asc" ? (
|
||||
<ArrowUpIcon className="ml-2 h-4 w-4" />
|
||||
) : (
|
||||
<CaretSortIcon className="ml-2 h-4 w-4" />
|
||||
)
|
||||
}
|
||||
</>}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
{!disableSorting &&<DropdownMenuContent align="start">
|
||||
{/* Sorting options */}
|
||||
<DropdownMenuItem onClick={() => column.toggleSorting(false)}>
|
||||
<ArrowUpIcon className="mr-2 h-3.5 w-3.5 text-muted-foreground/70" />
|
||||
Asc
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => column.toggleSorting(true)}>
|
||||
<ArrowDownIcon className="mr-2 h-3.5 w-3.5 text-muted-foreground/70" />
|
||||
Desc
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => column.toggleVisibility(false)}>
|
||||
<EyeNoneIcon className="mr-2 h-3.5 w-3.5 text-muted-foreground/70" />
|
||||
Hide
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>}
|
||||
</DropdownMenu>
|
||||
|
||||
{filterOptions.length > 0 && (<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="-ml-3 h-8 data-[state=open]:bg-accent"
|
||||
>
|
||||
{tempFilters.length === 0 ? <FilterIcon /> : <FilterX />}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
{/* Filtering options */}
|
||||
{filterOptions.length > 0 && (
|
||||
<>
|
||||
<DropdownMenuLabel>Filter Options</DropdownMenuLabel>
|
||||
{filterOptions.map((option) => (
|
||||
<DropdownMenuItem
|
||||
key={`${option.accessorKey}_${option.filterLabel}`}
|
||||
onClick={() => handleFilterToggle(`${option.accessorKey}_${option.filterLabel}`, column)}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
readOnly
|
||||
checked={tempFilters.includes(`${option.accessorKey}_${option.filterLabel}`)}
|
||||
className="mr-2"
|
||||
/>
|
||||
{option.filterLabel}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={clearFilters}>
|
||||
<Trash2 className="mr-1 h-3.5 w-3.5 text-muted-foreground/70" /> Clear Filters
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import dataAccess from "../adapter/db.client";
|
||||
import { Tags } from "../utils/cache-tag-generator.utils";
|
||||
import { App, AppBasicAuth, AppDomain, AppFileMount, AppPort, AppVolume, Prisma } from "@prisma/client";
|
||||
import { DefaultArgs } from "@prisma/client/runtime/library";
|
||||
import { AppExtendedModel } from "@/shared/model/app-extended.model";
|
||||
import { AppExtendedModel, AppWithProjectModel } from "@/shared/model/app-extended.model";
|
||||
import { ServiceException } from "@/shared/model/service.exception.model";
|
||||
import { KubeObjectNameUtils } from "../utils/kube-object-name.utils";
|
||||
import deploymentService from "./deployment.service";
|
||||
@@ -63,6 +63,8 @@ class AppService {
|
||||
revalidateTag(Tags.apps(existingApp.projectId));
|
||||
revalidateTag(Tags.app(existingApp.id));
|
||||
revalidateTag(Tags.projects());
|
||||
revalidateTag(Tags.roles());
|
||||
revalidateTag(Tags.users());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,6 +159,8 @@ class AppService {
|
||||
revalidateTag(Tags.apps(item.projectId as string));
|
||||
revalidateTag(Tags.app(item.id as string));
|
||||
revalidateTag(Tags.projects());
|
||||
revalidateTag(Tags.roles());
|
||||
revalidateTag(Tags.users());
|
||||
}
|
||||
return savedItem;
|
||||
}
|
||||
@@ -208,6 +212,14 @@ class AppService {
|
||||
return savedItem;
|
||||
}
|
||||
|
||||
async getDomainById(id: string) {
|
||||
return await dataAccess.client.appDomain.findFirstOrThrow({
|
||||
where: {
|
||||
id
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async deleteDomainById(id: string) {
|
||||
const existingDomain = await dataAccess.client.appDomain.findFirst({
|
||||
where: {
|
||||
@@ -399,6 +411,14 @@ class AppService {
|
||||
return savedItem;
|
||||
}
|
||||
|
||||
async getPortById(portId: string) {
|
||||
return await dataAccess.client.appPort.findFirstOrThrow({
|
||||
where: {
|
||||
id: portId
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async deletePortById(id: string) {
|
||||
const existingPort = await dataAccess.client.appPort.findFirst({
|
||||
where: {
|
||||
@@ -468,6 +488,42 @@ class AppService {
|
||||
revalidateTag(Tags.apps(existingItem.app.projectId));
|
||||
}
|
||||
}
|
||||
|
||||
async getBasicAuthById(id: string) {
|
||||
return await dataAccess.client.appBasicAuth.findFirstOrThrow({
|
||||
where: {
|
||||
id
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async getAll() {
|
||||
const apps = await dataAccess.client.app.findMany({
|
||||
orderBy: {
|
||||
name: 'asc'
|
||||
},
|
||||
include: {
|
||||
project: true,
|
||||
}
|
||||
}) as AppWithProjectModel[];
|
||||
|
||||
apps.sort((a, b) => {
|
||||
if (a.project.name.toLocaleLowerCase() < b.project.name.toLocaleLowerCase()) {
|
||||
return -1;
|
||||
}
|
||||
if (a.project.name.toLocaleLowerCase() > b.project.name.toLocaleLowerCase()) {
|
||||
return 1;
|
||||
}
|
||||
if (a.name.toLocaleLowerCase() < b.name.toLocaleLowerCase()) {
|
||||
return -1;
|
||||
}
|
||||
if (a.name.toLocaleLowerCase() > b.name.toLocaleLowerCase()) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
return apps;
|
||||
}
|
||||
}
|
||||
|
||||
const appService = new AppService();
|
||||
|
||||
@@ -191,6 +191,23 @@ class BuildService {
|
||||
}
|
||||
}
|
||||
|
||||
async getBuildByName(buildName: string) {
|
||||
const jobs = await k3s.batch.listNamespacedJob(BUILD_NAMESPACE);
|
||||
return jobs.body.items.find((job) => job.metadata?.name === buildName);
|
||||
}
|
||||
|
||||
async getAppIdByBuildName(buildName: string) {
|
||||
const job = await this.getBuildByName(buildName);
|
||||
if (!job) {
|
||||
throw new ServiceException(`No build found with name ${buildName}`);
|
||||
}
|
||||
const appId = job.metadata?.annotations?.[Constants.QS_ANNOTATION_APP_ID];
|
||||
if (!appId) {
|
||||
throw new ServiceException(`No appId found for build ${buildName}`);
|
||||
}
|
||||
return appId;
|
||||
}
|
||||
|
||||
async deleteBuild(buildName: string) {
|
||||
await k3s.batch.deleteNamespacedJob(buildName, BUILD_NAMESPACE);
|
||||
console.log(`Deleted build job ${buildName}`);
|
||||
|
||||
@@ -7,6 +7,7 @@ import deploymentService from "./deployment.service";
|
||||
import namespaceService from "./namespace.service";
|
||||
import buildService from "./build.service";
|
||||
import traefikMeDomainStandaloneService from "./standalone-services/traefik-me-domain-standalone.service";
|
||||
import { ProjectExtendedModel } from "@/shared/model/project-extended.model";
|
||||
|
||||
class ProjectService {
|
||||
|
||||
@@ -25,10 +26,12 @@ class ProjectService {
|
||||
});
|
||||
} finally {
|
||||
revalidateTag(Tags.projects());
|
||||
revalidateTag(Tags.roles());
|
||||
revalidateTag(Tags.users());
|
||||
}
|
||||
}
|
||||
|
||||
async getAllProjects() {
|
||||
async getAllProjects(): Promise<ProjectExtendedModel[]> {
|
||||
return await unstable_cache(() => dataAccess.client.project.findMany({
|
||||
include: {
|
||||
apps: true
|
||||
|
||||
@@ -2,18 +2,24 @@ import dataAccess from "../../adapter/db.client";
|
||||
import bcrypt from "bcrypt";
|
||||
import { randomBytes } from "crypto";
|
||||
import quickStackService from "../qs.service";
|
||||
import { adminRoleName } from "../../../shared/model/role-extended.model.ts";
|
||||
|
||||
class PasswordChangeService {
|
||||
|
||||
async changeAdminPasswordAndPrintNewPassword() {
|
||||
const firstCreatedUser = await dataAccess.client.user.findFirst({
|
||||
where: {
|
||||
userGroup: {
|
||||
name: adminRoleName
|
||||
}
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'asc'
|
||||
}
|
||||
});
|
||||
|
||||
if (!firstCreatedUser) {
|
||||
console.error("No users found. QuickStack is not configured yet. Open your browser to setup quickstack");
|
||||
console.error("No admin users found. QuickStack is not configured yet. Open your browser to setup quickstack");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -36,7 +42,7 @@ class PasswordChangeService {
|
||||
console.log('******* Password change *******');
|
||||
console.log('*******************************');
|
||||
console.log(``);
|
||||
console.log(`New password for user ${firstCreatedUser.email} is: ${generatedPassword}`);
|
||||
console.log(`New password for admin user ${firstCreatedUser.email} is: ${generatedPassword}`);
|
||||
console.log(``);
|
||||
console.log('*******************************');
|
||||
console.log('*******************************');
|
||||
|
||||
300
src/server/services/user-group.service.ts
Normal file
300
src/server/services/user-group.service.ts
Normal file
@@ -0,0 +1,300 @@
|
||||
import { Prisma, RoleAppPermission, UserGroup } from "@prisma/client";
|
||||
import dataAccess from "../adapter/db.client";
|
||||
import { revalidateTag, unstable_cache } from "next/cache";
|
||||
import { Tags } from "../utils/cache-tag-generator.utils";
|
||||
import { ServiceException } from "@/shared/model/service.exception.model";
|
||||
import { RoleEditModel } from "@/shared/model/role-edit.model";
|
||||
import { adminRoleName } from "@/shared/model/role-extended.model.ts";
|
||||
import { UserGroupExtended } from "@/shared/model/sim-session.model";
|
||||
|
||||
export class UserGroupService {
|
||||
|
||||
async getRoleByUserMail(email: string): Promise<UserGroupExtended | null> {
|
||||
return await unstable_cache(async (mail: string) => await dataAccess.client.user.findFirst({
|
||||
select: {
|
||||
userGroup: {
|
||||
select: {
|
||||
name: true,
|
||||
id: true,
|
||||
canAccessBackups: true,
|
||||
roleProjectPermissions: {
|
||||
select: {
|
||||
projectId: true,
|
||||
project: {
|
||||
select: {
|
||||
apps: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
createApps: true,
|
||||
deleteApps: true,
|
||||
writeApps: true,
|
||||
readApps: true,
|
||||
roleAppPermissions: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
where: {
|
||||
email: mail
|
||||
}
|
||||
}).then(user => {
|
||||
return user?.userGroup ?? null;
|
||||
}),
|
||||
[Tags.roles(), Tags.users()], {
|
||||
tags: [Tags.roles(), Tags.users()]
|
||||
})(email);
|
||||
}
|
||||
|
||||
async saveWithPermissions(item: RoleEditModel) {
|
||||
try {
|
||||
if (item.name === adminRoleName) {
|
||||
throw new ServiceException("You cannot assign the name 'admin' to a role");
|
||||
}
|
||||
await dataAccess.client.$transaction(async tx => {
|
||||
// save role first
|
||||
|
||||
let savedRole: UserGroup;
|
||||
if (item.id) {
|
||||
savedRole = await tx.userGroup.update({
|
||||
where: {
|
||||
id: item.id as string
|
||||
},
|
||||
data: {
|
||||
name: item.name,
|
||||
canAccessBackups: item.canAccessBackups,
|
||||
}
|
||||
});
|
||||
} else {
|
||||
savedRole = await tx.userGroup.create({
|
||||
data: {
|
||||
name: item.name,
|
||||
canAccessBackups: item.canAccessBackups,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// save project and app permissions
|
||||
|
||||
await tx.roleProjectPermission.deleteMany({
|
||||
where: {
|
||||
userGroupId: savedRole.id
|
||||
}
|
||||
});
|
||||
|
||||
for (let projectRolePermission of item.roleProjectPermissions) {
|
||||
const forThisProjectCustomAppRolesExist = projectRolePermission.roleAppPermissions.length > 0;
|
||||
const projectRolePermissionData = {
|
||||
userGroupId: savedRole.id,
|
||||
projectId: projectRolePermission.projectId,
|
||||
createApps: forThisProjectCustomAppRolesExist ? false : projectRolePermission.createApps,
|
||||
deleteApps: forThisProjectCustomAppRolesExist ? false : projectRolePermission.deleteApps,
|
||||
writeApps: forThisProjectCustomAppRolesExist ? false : projectRolePermission.writeApps,
|
||||
readApps: projectRolePermission.readApps
|
||||
};
|
||||
const savedProjectRolePermission = await tx.roleProjectPermission.create({
|
||||
data: projectRolePermissionData
|
||||
});
|
||||
|
||||
// save app permissions
|
||||
await tx.roleAppPermission.deleteMany({
|
||||
where: {
|
||||
roleProjectPermissionId: savedProjectRolePermission.id
|
||||
}
|
||||
});
|
||||
|
||||
await tx.roleAppPermission.createMany({
|
||||
data: projectRolePermission.roleAppPermissions.map((app) => ({
|
||||
...app,
|
||||
roleProjectPermissionId: savedProjectRolePermission.id
|
||||
}))
|
||||
});
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
revalidateTag(Tags.roles());
|
||||
revalidateTag(Tags.users());
|
||||
}
|
||||
}
|
||||
|
||||
async save(item: Prisma.UserGroupUncheckedCreateInput | Prisma.UserGroupUncheckedUpdateInput) {
|
||||
try {
|
||||
if (item.name === adminRoleName) {
|
||||
throw new ServiceException("You cannot assign the name 'admin' to a role");
|
||||
}
|
||||
if (item.id) {
|
||||
await dataAccess.client.userGroup.update({
|
||||
where: {
|
||||
id: item.id as string
|
||||
},
|
||||
data: item
|
||||
});
|
||||
} else {
|
||||
await dataAccess.client.userGroup.create({
|
||||
data: item as Prisma.UserGroupUncheckedCreateInput
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
revalidateTag(Tags.roles());
|
||||
revalidateTag(Tags.users());
|
||||
}
|
||||
}
|
||||
|
||||
async getAll(): Promise<UserGroupExtended[]> {
|
||||
return await unstable_cache(async () => await dataAccess.client.userGroup.findMany({
|
||||
include: {
|
||||
roleProjectPermissions: {
|
||||
select: {
|
||||
projectId: true,
|
||||
project: {
|
||||
select: {
|
||||
apps: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
createApps: true,
|
||||
deleteApps: true,
|
||||
writeApps: true,
|
||||
readApps: true,
|
||||
roleAppPermissions: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
[Tags.roles()], {
|
||||
tags: [Tags.roles()]
|
||||
})();
|
||||
}
|
||||
async getById(id: string): Promise<UserGroup> {
|
||||
return await unstable_cache(async () => await dataAccess.client.userGroup.findFirstOrThrow({
|
||||
where: {
|
||||
id
|
||||
},
|
||||
include: {
|
||||
roleProjectPermissions: {
|
||||
select: {
|
||||
projectId: true,
|
||||
project: {
|
||||
select: {
|
||||
apps: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
createApps: true,
|
||||
deleteApps: true,
|
||||
writeApps: true,
|
||||
readApps: true,
|
||||
roleAppPermissions: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
[Tags.roles(), id], {
|
||||
tags: [Tags.roles()]
|
||||
})();
|
||||
}
|
||||
|
||||
async assignUserToRole(userId: string, userGroupId: string) {
|
||||
try {
|
||||
await dataAccess.client.user.update({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
data: {
|
||||
userGroupId,
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
revalidateTag(Tags.roles());
|
||||
revalidateTag(Tags.users());
|
||||
}
|
||||
}
|
||||
|
||||
async deleteById(id: string) {
|
||||
try {
|
||||
await dataAccess.client.userGroup.delete({
|
||||
where: {
|
||||
id
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
revalidateTag(Tags.roles());
|
||||
revalidateTag(Tags.users());
|
||||
}
|
||||
}
|
||||
|
||||
async getOrCreateAdminRole() {
|
||||
let adminRole = await dataAccess.client.userGroup.findFirst({
|
||||
where: {
|
||||
name: adminRoleName
|
||||
}
|
||||
});
|
||||
if (!adminRole) {
|
||||
adminRole = await dataAccess.client.userGroup.create({
|
||||
data: {
|
||||
name: adminRoleName
|
||||
}
|
||||
});
|
||||
}
|
||||
return adminRole;
|
||||
}
|
||||
|
||||
async createDefaultRolesIfNotExists() {
|
||||
try {
|
||||
const dbAdminRole = await dataAccess.client.userGroup.findFirst({
|
||||
where: {
|
||||
name: {
|
||||
in: [adminRoleName]
|
||||
}
|
||||
},
|
||||
include: {
|
||||
users: true
|
||||
}
|
||||
});
|
||||
if (!dbAdminRole) {
|
||||
console.warn("*** No admin users found. Creating default admin role ***");
|
||||
const adminRole = await this.getOrCreateAdminRole();
|
||||
await dataAccess.client.user.updateMany({
|
||||
where: {
|
||||
userGroupId: null
|
||||
},
|
||||
data: {
|
||||
userGroupId: adminRole.id
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (dbAdminRole.users.length === 0) {
|
||||
// making all users to admins
|
||||
console.warn("*** No admin users found. Assigning all users to admin role ***");
|
||||
const adminRole = await this.getOrCreateAdminRole();
|
||||
await dataAccess.client.user.updateMany({
|
||||
data: {
|
||||
userGroupId: adminRole.id
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
} finally {
|
||||
revalidateTag(Tags.roles());
|
||||
revalidateTag(Tags.users());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const userGroupService = new UserGroupService();
|
||||
export default userGroupService;
|
||||
@@ -1,4 +1,4 @@
|
||||
import { User } from "@prisma/client";
|
||||
import { Prisma, User } from "@prisma/client";
|
||||
import dataAccess from "../adapter/db.client";
|
||||
import { revalidateTag, unstable_cache } from "next/cache";
|
||||
import { Tags } from "../utils/cache-tag-generator.utils";
|
||||
@@ -6,6 +6,7 @@ import bcrypt from "bcrypt";
|
||||
import { ServiceException } from "@/shared/model/service.exception.model";
|
||||
import QRCode from "qrcode";
|
||||
import * as OTPAuth from "otpauth";
|
||||
import { UserExtended } from "@/shared/model/user-extended.model";
|
||||
|
||||
const saltRounds = 10;
|
||||
|
||||
@@ -39,6 +40,36 @@ export class UserService {
|
||||
}
|
||||
}
|
||||
|
||||
async changePasswordImediately(userMail: string, newPassword: string) {
|
||||
try {
|
||||
const hashedPassword = await bcrypt.hash(newPassword, saltRounds);
|
||||
await dataAccess.client.user.update({
|
||||
where: {
|
||||
email: userMail
|
||||
},
|
||||
data: {
|
||||
password: hashedPassword
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
revalidateTag(Tags.users());
|
||||
}
|
||||
}
|
||||
|
||||
async updateUser(user: Prisma.UserUncheckedUpdateInput) {
|
||||
try {
|
||||
delete (user as any).password;
|
||||
await dataAccess.client.user.update({
|
||||
where: {
|
||||
email: user.email as string
|
||||
},
|
||||
data: user
|
||||
});
|
||||
} finally {
|
||||
revalidateTag(Tags.users());
|
||||
}
|
||||
}
|
||||
|
||||
async maptoDtoUser(user: User) {
|
||||
return {
|
||||
email: user.email,
|
||||
@@ -69,13 +100,14 @@ export class UserService {
|
||||
}
|
||||
}
|
||||
|
||||
async registerUser(email: string, password: string) {
|
||||
async registerUser(email: string, password: string, userGroupId: string | null) {
|
||||
try {
|
||||
const hashedPassword = await bcrypt.hash(password, saltRounds);
|
||||
const user = await dataAccess.client.user.create({
|
||||
data: {
|
||||
email,
|
||||
password: hashedPassword
|
||||
password: hashedPassword,
|
||||
userGroupId
|
||||
}
|
||||
});
|
||||
return user;
|
||||
@@ -84,13 +116,38 @@ export class UserService {
|
||||
}
|
||||
}
|
||||
|
||||
async getAllUsers() {
|
||||
return await unstable_cache(async () => await dataAccess.client.user.findMany(),
|
||||
async getAllUsers(): Promise<UserExtended[]> {
|
||||
return await unstable_cache(async () => await dataAccess.client.user.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
userGroupId: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
userGroup: true
|
||||
}
|
||||
}),
|
||||
[Tags.users()], {
|
||||
tags: [Tags.users()]
|
||||
})();
|
||||
}
|
||||
|
||||
async getUserById(id: string): Promise<UserExtended> {
|
||||
return await dataAccess.client.user.findFirstOrThrow({
|
||||
where: {
|
||||
id
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
userGroupId: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
userGroup: true
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async getUserByEmail(email: string) {
|
||||
return await dataAccess.client.user.findFirstOrThrow({
|
||||
where: {
|
||||
@@ -192,6 +249,22 @@ export class UserService {
|
||||
revalidateTag(Tags.users());
|
||||
}
|
||||
}
|
||||
|
||||
async deleteUserById(id: string) {
|
||||
try {
|
||||
const allUsers = await this.getAllUsers();
|
||||
if (allUsers.length <= 1) {
|
||||
throw new ServiceException("You cannot delete the last user");
|
||||
}
|
||||
await dataAccess.client.user.delete({
|
||||
where: {
|
||||
id
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
revalidateTag(Tags.users());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const userService = new UserService();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ServiceException } from "@/shared/model/service.exception.model";
|
||||
import { UserSession } from "@/shared/model/sim-session.model";
|
||||
import { UserGroupExtended, UserSession } from "@/shared/model/sim-session.model";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { ZodRawShape, ZodObject, objectUtil, baseObjectOutputType, z, ZodType } from "zod";
|
||||
import { redirect } from "next/navigation";
|
||||
@@ -7,6 +7,9 @@ import { ServerActionResult } from "@/shared/model/server-action-error-return.mo
|
||||
import { FormValidationException } from "@/shared/model/form-validation-exception.model";
|
||||
import { authOptions } from "@/server/utils/auth-options";
|
||||
import { NextResponse } from "next/server";
|
||||
import userGroupService from "../services/user-group.service";
|
||||
import { RolePermissionEnum } from "@/shared/model/role-extended.model.ts";
|
||||
import { UserGroupUtils } from "../../shared/utils/role.utils";
|
||||
|
||||
/**
|
||||
* THIS FUNCTION RETURNS NULL IF NO USER IS LOGGED IN
|
||||
@@ -17,8 +20,13 @@ export async function getUserSession(): Promise<UserSession | null> {
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
let userGroup: UserGroupExtended | null = null;
|
||||
if (!!session?.user?.email) {
|
||||
userGroup = await userGroupService.getRoleByUserMail(session.user.email);
|
||||
}
|
||||
return {
|
||||
email: session?.user?.email as string
|
||||
email: session?.user?.email as string,
|
||||
userGroup: userGroup ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -31,6 +39,66 @@ export async function getAuthUserSession(): Promise<UserSession> {
|
||||
return session;
|
||||
}
|
||||
|
||||
export async function getAdminUserSession(): Promise<UserSession> {
|
||||
const session = await getAuthUserSession();
|
||||
if (!UserGroupUtils.isAdmin(session)) {
|
||||
console.error('User is not admin.');
|
||||
throw new ServiceException('User is not authorized for this action.');
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
export async function isAuthorizedForBackups() {
|
||||
const session = await getAuthUserSession();
|
||||
if (!UserGroupUtils.sessionHasAccessToBackups(session)) {
|
||||
console.error('User is not authorized for backups.');
|
||||
throw new ServiceException('User is not authorized for this action.');
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
export async function isAuthorizedReadForApp(appId: string) {
|
||||
const session = await getAuthUserSession();
|
||||
if (UserGroupUtils.isAdmin(session)) {
|
||||
return session;
|
||||
}
|
||||
if (!session.userGroup) {
|
||||
console.error('User is not authorized for app: ' + appId);
|
||||
throw new ServiceException('User is not authorized for this action.');
|
||||
}
|
||||
const roleHasReadAccessForApp = UserGroupUtils.sessionHasReadAccessForApp(session, appId);
|
||||
if (!roleHasReadAccessForApp) {
|
||||
console.error('User is not authorized for app: ' + appId);
|
||||
throw new ServiceException('User is not authorized for this action.');
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
export async function isAuthorizedWriteForApp(appId: string) {
|
||||
const session = await getAuthUserSession();
|
||||
if (UserGroupUtils.isAdmin(session)) {
|
||||
return session;
|
||||
}
|
||||
if (!session.userGroup) {
|
||||
console.error('User is not authorized for app: ' + appId);
|
||||
throw new ServiceException('User is not authorized for this action.');
|
||||
}
|
||||
const roleHasReadAccessForApp = UserGroupUtils.sessionHasWriteAccessForApp(session, appId);
|
||||
if (!roleHasReadAccessForApp) {
|
||||
console.error('User is not authorized for app: ' + appId);
|
||||
throw new ServiceException('User is not authorized for this action.');
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
export async function safeGetUserPermissionForApp(appId: string) {
|
||||
const session = await getUserSession();
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
return UserGroupUtils.getRolePermissionForApp(session, appId);
|
||||
}
|
||||
|
||||
export async function saveFormAction<ReturnType, TInputData, ZodType extends ZodRawShape>(
|
||||
inputData: TInputData,
|
||||
validationModel: ZodObject<ZodType>,
|
||||
|
||||
@@ -4,6 +4,10 @@ export class Tags {
|
||||
return `users`;
|
||||
}
|
||||
|
||||
static roles() {
|
||||
return `roles`;
|
||||
}
|
||||
|
||||
static projects() {
|
||||
return `projects`;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { z } from "zod";
|
||||
import { AppBasicAuthModel, AppDomainModel, AppFileMountModel, AppModel, AppPortModel, AppVolumeModel, ProjectModel, VolumeBackupModel } from "./generated-zod";
|
||||
import { App, Project } from "@prisma/client";
|
||||
|
||||
export const AppExtendedZodModel= z.lazy(() => AppModel.extend({
|
||||
project: ProjectModel,
|
||||
@@ -11,3 +12,7 @@ export const AppExtendedZodModel= z.lazy(() => AppModel.extend({
|
||||
}))
|
||||
|
||||
export type AppExtendedModel = z.infer<typeof AppExtendedZodModel>;
|
||||
|
||||
export type AppWithProjectModel = App & {
|
||||
project: Project;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as z from "zod"
|
||||
|
||||
import { CompleteProject, RelatedProjectModel, CompleteAppDomain, RelatedAppDomainModel, CompleteAppPort, RelatedAppPortModel, CompleteAppVolume, RelatedAppVolumeModel, CompleteAppFileMount, RelatedAppFileMountModel, CompleteAppBasicAuth, RelatedAppBasicAuthModel } from "./index"
|
||||
import { CompleteProject, RelatedProjectModel, CompleteAppDomain, RelatedAppDomainModel, CompleteAppPort, RelatedAppPortModel, CompleteAppVolume, RelatedAppVolumeModel, CompleteAppFileMount, RelatedAppFileMountModel, CompleteAppBasicAuth, RelatedAppBasicAuthModel, CompleteRoleAppPermission, RelatedRoleAppPermissionModel } from "./index"
|
||||
|
||||
export const AppModel = z.object({
|
||||
id: z.string(),
|
||||
@@ -34,6 +34,7 @@ export interface CompleteApp extends z.infer<typeof AppModel> {
|
||||
appVolumes: CompleteAppVolume[]
|
||||
appFileMounts: CompleteAppFileMount[]
|
||||
appBasicAuths: CompleteAppBasicAuth[]
|
||||
roleAppPermissions: CompleteRoleAppPermission[]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -48,4 +49,5 @@ export const RelatedAppModel: z.ZodSchema<CompleteApp> = z.lazy(() => AppModel.e
|
||||
appVolumes: RelatedAppVolumeModel.array(),
|
||||
appFileMounts: RelatedAppFileMountModel.array(),
|
||||
appBasicAuths: RelatedAppBasicAuthModel.array(),
|
||||
roleAppPermissions: RelatedRoleAppPermissionModel.array(),
|
||||
}))
|
||||
|
||||
@@ -3,6 +3,9 @@ export * from "./session"
|
||||
export * from "./user"
|
||||
export * from "./verificationtoken"
|
||||
export * from "./authenticator"
|
||||
export * from "./usergroup"
|
||||
export * from "./roleprojectpermission"
|
||||
export * from "./roleapppermission"
|
||||
export * from "./project"
|
||||
export * from "./app"
|
||||
export * from "./appport"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as z from "zod"
|
||||
|
||||
import { CompleteApp, RelatedAppModel } from "./index"
|
||||
import { CompleteApp, RelatedAppModel, CompleteRoleProjectPermission, RelatedRoleProjectPermissionModel } from "./index"
|
||||
|
||||
export const ProjectModel = z.object({
|
||||
id: z.string(),
|
||||
@@ -11,6 +11,7 @@ export const ProjectModel = z.object({
|
||||
|
||||
export interface CompleteProject extends z.infer<typeof ProjectModel> {
|
||||
apps: CompleteApp[]
|
||||
roleProjectPermissions: CompleteRoleProjectPermission[]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -20,4 +21,5 @@ export interface CompleteProject extends z.infer<typeof ProjectModel> {
|
||||
*/
|
||||
export const RelatedProjectModel: z.ZodSchema<CompleteProject> = z.lazy(() => ProjectModel.extend({
|
||||
apps: RelatedAppModel.array(),
|
||||
roleProjectPermissions: RelatedRoleProjectPermissionModel.array(),
|
||||
}))
|
||||
|
||||
27
src/shared/model/generated-zod/role.ts
Normal file
27
src/shared/model/generated-zod/role.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import * as z from "zod"
|
||||
|
||||
import { CompleteUser, RelatedUserModel, CompleteRoleProjectPermission, RelatedRoleProjectPermissionModel } from "./index"
|
||||
|
||||
export const RoleModel = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
description: z.string().nullish(),
|
||||
canAccessBackups: z.boolean(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
})
|
||||
|
||||
export interface CompleteRole extends z.infer<typeof RoleModel> {
|
||||
users: CompleteUser[]
|
||||
roleProjectPermissions: CompleteRoleProjectPermission[]
|
||||
}
|
||||
|
||||
/**
|
||||
* RelatedRoleModel contains all relations on your model in addition to the scalars
|
||||
*
|
||||
* NOTE: Lazy required in case of potential circular dependencies within schema
|
||||
*/
|
||||
export const RelatedRoleModel: z.ZodSchema<CompleteRole> = z.lazy(() => RoleModel.extend({
|
||||
users: RelatedUserModel.array(),
|
||||
roleProjectPermissions: RelatedRoleProjectPermissionModel.array(),
|
||||
}))
|
||||
27
src/shared/model/generated-zod/roleapppermission.ts
Normal file
27
src/shared/model/generated-zod/roleapppermission.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import * as z from "zod"
|
||||
|
||||
import { CompleteApp, RelatedAppModel, CompleteRoleProjectPermission, RelatedRoleProjectPermissionModel } from "./index"
|
||||
|
||||
export const RoleAppPermissionModel = z.object({
|
||||
id: z.string(),
|
||||
appId: z.string(),
|
||||
permission: z.string(),
|
||||
roleProjectPermissionId: z.string().nullish(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
})
|
||||
|
||||
export interface CompleteRoleAppPermission extends z.infer<typeof RoleAppPermissionModel> {
|
||||
app: CompleteApp
|
||||
roleProjectPermission?: CompleteRoleProjectPermission | null
|
||||
}
|
||||
|
||||
/**
|
||||
* RelatedRoleAppPermissionModel contains all relations on your model in addition to the scalars
|
||||
*
|
||||
* NOTE: Lazy required in case of potential circular dependencies within schema
|
||||
*/
|
||||
export const RelatedRoleAppPermissionModel: z.ZodSchema<CompleteRoleAppPermission> = z.lazy(() => RoleAppPermissionModel.extend({
|
||||
app: RelatedAppModel,
|
||||
roleProjectPermission: RelatedRoleProjectPermissionModel.nullish(),
|
||||
}))
|
||||
32
src/shared/model/generated-zod/roleprojectpermission.ts
Normal file
32
src/shared/model/generated-zod/roleprojectpermission.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import * as z from "zod"
|
||||
|
||||
import { CompleteUserGroup, RelatedUserGroupModel, CompleteProject, RelatedProjectModel, CompleteRoleAppPermission, RelatedRoleAppPermissionModel } from "./index"
|
||||
|
||||
export const RoleProjectPermissionModel = z.object({
|
||||
id: z.string(),
|
||||
userGroupId: z.string(),
|
||||
projectId: z.string(),
|
||||
createApps: z.boolean(),
|
||||
deleteApps: z.boolean(),
|
||||
writeApps: z.boolean(),
|
||||
readApps: z.boolean(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
})
|
||||
|
||||
export interface CompleteRoleProjectPermission extends z.infer<typeof RoleProjectPermissionModel> {
|
||||
userGroup: CompleteUserGroup
|
||||
project: CompleteProject
|
||||
roleAppPermissions: CompleteRoleAppPermission[]
|
||||
}
|
||||
|
||||
/**
|
||||
* RelatedRoleProjectPermissionModel contains all relations on your model in addition to the scalars
|
||||
*
|
||||
* NOTE: Lazy required in case of potential circular dependencies within schema
|
||||
*/
|
||||
export const RelatedRoleProjectPermissionModel: z.ZodSchema<CompleteRoleProjectPermission> = z.lazy(() => RoleProjectPermissionModel.extend({
|
||||
userGroup: RelatedUserGroupModel,
|
||||
project: RelatedProjectModel,
|
||||
roleAppPermissions: RelatedRoleAppPermissionModel.array(),
|
||||
}))
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as z from "zod"
|
||||
|
||||
import { CompleteAccount, RelatedAccountModel, CompleteSession, RelatedSessionModel, CompleteAuthenticator, RelatedAuthenticatorModel } from "./index"
|
||||
import { CompleteUserGroup, RelatedUserGroupModel, CompleteAccount, RelatedAccountModel, CompleteSession, RelatedSessionModel, CompleteAuthenticator, RelatedAuthenticatorModel } from "./index"
|
||||
|
||||
export const UserModel = z.object({
|
||||
id: z.string(),
|
||||
@@ -11,11 +11,13 @@ export const UserModel = z.object({
|
||||
twoFaSecret: z.string().nullish(),
|
||||
twoFaEnabled: z.boolean(),
|
||||
image: z.string().nullish(),
|
||||
userGroupId: z.string().nullish(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
})
|
||||
|
||||
export interface CompleteUser extends z.infer<typeof UserModel> {
|
||||
userGroup?: CompleteUserGroup | null
|
||||
accounts: CompleteAccount[]
|
||||
sessions: CompleteSession[]
|
||||
Authenticator: CompleteAuthenticator[]
|
||||
@@ -27,6 +29,7 @@ export interface CompleteUser extends z.infer<typeof UserModel> {
|
||||
* NOTE: Lazy required in case of potential circular dependencies within schema
|
||||
*/
|
||||
export const RelatedUserModel: z.ZodSchema<CompleteUser> = z.lazy(() => UserModel.extend({
|
||||
userGroup: RelatedUserGroupModel.nullish(),
|
||||
accounts: RelatedAccountModel.array(),
|
||||
sessions: RelatedSessionModel.array(),
|
||||
Authenticator: RelatedAuthenticatorModel.array(),
|
||||
|
||||
27
src/shared/model/generated-zod/usergroup.ts
Normal file
27
src/shared/model/generated-zod/usergroup.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import * as z from "zod"
|
||||
|
||||
import { CompleteUser, RelatedUserModel, CompleteRoleProjectPermission, RelatedRoleProjectPermissionModel } from "./index"
|
||||
|
||||
export const UserGroupModel = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
description: z.string().nullish(),
|
||||
canAccessBackups: z.boolean(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
})
|
||||
|
||||
export interface CompleteUserGroup extends z.infer<typeof UserGroupModel> {
|
||||
users: CompleteUser[]
|
||||
roleProjectPermissions: CompleteRoleProjectPermission[]
|
||||
}
|
||||
|
||||
/**
|
||||
* RelatedUserGroupModel contains all relations on your model in addition to the scalars
|
||||
*
|
||||
* NOTE: Lazy required in case of potential circular dependencies within schema
|
||||
*/
|
||||
export const RelatedUserGroupModel: z.ZodSchema<CompleteUserGroup> = z.lazy(() => UserGroupModel.extend({
|
||||
users: RelatedUserModel.array(),
|
||||
roleProjectPermissions: RelatedRoleProjectPermissionModel.array(),
|
||||
}))
|
||||
5
src/shared/model/project-extended.model.ts
Normal file
5
src/shared/model/project-extended.model.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { App, Project } from "@prisma/client";
|
||||
|
||||
export type ProjectExtendedModel = Project & {
|
||||
apps: App[];
|
||||
}
|
||||
28
src/shared/model/role-edit.model.ts
Normal file
28
src/shared/model/role-edit.model.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { stringToNumber } from "@/shared/utils/zod.utils";
|
||||
import { z } from "zod";
|
||||
|
||||
|
||||
const roleAppPermissionZodModle = z.object({
|
||||
appId: z.string(),
|
||||
permission: z.string(),
|
||||
});
|
||||
|
||||
const RoleProjectPermissionSchema = z.object({
|
||||
projectId: z.string(),
|
||||
createApps: z.boolean(),
|
||||
deleteApps: z.boolean(),
|
||||
writeApps: z.boolean(),
|
||||
readApps: z.boolean(),
|
||||
roleAppPermissions: z.array(roleAppPermissionZodModle).optional().default([]),
|
||||
});
|
||||
|
||||
// Schema for UserRole.
|
||||
export const roleEditZodModel = z.object({
|
||||
id: z.string().trim().optional(),
|
||||
name: z.string().trim().min(1),
|
||||
canAccessBackups: z.boolean().optional().default(false),
|
||||
roleProjectPermissions: z.array(RoleProjectPermissionSchema).optional().default([]),
|
||||
});
|
||||
|
||||
|
||||
export type RoleEditModel = z.infer<typeof roleEditZodModel>;
|
||||
17
src/shared/model/role-extended.model.ts.ts
Normal file
17
src/shared/model/role-extended.model.ts.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { RoleAppPermission, User, UserGroup } from "@prisma/client";
|
||||
|
||||
export type RoleExtended = UserGroup & {
|
||||
roleAppPermissions: (RoleAppPermission & {
|
||||
app: {
|
||||
name: string;
|
||||
};
|
||||
})[];
|
||||
}
|
||||
|
||||
export enum RolePermissionEnum {
|
||||
READ = 'READ',
|
||||
READWRITE = 'READWRITE'
|
||||
}
|
||||
|
||||
|
||||
export const adminRoleName = "admin";
|
||||
@@ -1,5 +1,28 @@
|
||||
import { RoleAppPermission } from "@prisma/client";
|
||||
import { Session } from "next-auth";
|
||||
import { RolePermissionEnum } from "./role-extended.model.ts";
|
||||
|
||||
export interface UserSession {
|
||||
email: string;
|
||||
userGroup?: UserGroupExtended;
|
||||
}
|
||||
|
||||
export type UserGroupExtended = {
|
||||
name: string;
|
||||
id: string;
|
||||
canAccessBackups: boolean;
|
||||
roleProjectPermissions: {
|
||||
projectId: string;
|
||||
project: {
|
||||
apps: {
|
||||
id: string;
|
||||
name: string;
|
||||
}[];
|
||||
};
|
||||
createApps: boolean;
|
||||
deleteApps: boolean;
|
||||
writeApps: boolean;
|
||||
readApps: boolean;
|
||||
roleAppPermissions: RoleAppPermission[];
|
||||
}[];
|
||||
};
|
||||
11
src/shared/model/user-edit.model.ts
Normal file
11
src/shared/model/user-edit.model.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { stringToNumber } from "@/shared/utils/zod.utils";
|
||||
import { z } from "zod";
|
||||
|
||||
export const userEditZodModel = z.object({
|
||||
id: z.string().trim().optional(),
|
||||
email: z.string().trim().min(1),
|
||||
newPassword: z.string().optional(),
|
||||
userGroupId: z.string().trim().nullable(),
|
||||
})
|
||||
|
||||
export type UserEditModel = z.infer<typeof userEditZodModel>;
|
||||
11
src/shared/model/user-extended.model.ts
Normal file
11
src/shared/model/user-extended.model.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { User, UserGroup } from "@prisma/client";
|
||||
import { UserGroupExtended } from "./sim-session.model";
|
||||
|
||||
export type UserExtended = {
|
||||
id: string;
|
||||
userGroup: UserGroup | null;
|
||||
userGroupId: string | null;
|
||||
email: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
16
src/shared/utils/react-node.utils.ts
Normal file
16
src/shared/utils/react-node.utils.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { isValidElement } from "react";
|
||||
|
||||
export class ReactNodeUtils {
|
||||
static getTextFromReactElement(element: React.ReactNode): string {
|
||||
if (typeof element === "string" || typeof element === "number") {
|
||||
return element.toString();
|
||||
}
|
||||
if (isValidElement(element)) {
|
||||
return this.getTextFromReactElement(element.props.children);
|
||||
}
|
||||
if (Array.isArray(element)) {
|
||||
return element.map(child => this.getTextFromReactElement(child)).join("");
|
||||
}
|
||||
return "";
|
||||
}
|
||||
}
|
||||
114
src/shared/utils/role.utils.ts
Normal file
114
src/shared/utils/role.utils.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { adminRoleName, RolePermissionEnum } from "@/shared/model/role-extended.model.ts";
|
||||
import { UserSession } from "@/shared/model/sim-session.model";
|
||||
|
||||
export class UserGroupUtils {
|
||||
|
||||
static sessionHasReadAccessToProject(session: UserSession, projectId: string) {
|
||||
if (this.isAdmin(session)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const projectPermission = UserGroupUtils.getProjectPermissionForProjectId(session, projectId);
|
||||
if (!projectPermission) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (projectPermission.roleAppPermissions.length > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return projectPermission.readApps;
|
||||
}
|
||||
|
||||
private static getProjectPermissionForProjectId(session: UserSession, projectId: string) {
|
||||
return session.userGroup?.roleProjectPermissions?.find((projectPermission) => projectPermission.projectId === projectId);
|
||||
}
|
||||
|
||||
private static getProjectPermissionForAppId(session: UserSession, appId: string) {
|
||||
return session.userGroup?.roleProjectPermissions?.find((projectPermission) => {
|
||||
return projectPermission.project?.apps?.some(app => app.id === appId);
|
||||
});
|
||||
}
|
||||
|
||||
static getRolePermissionForApp(session: UserSession, appId: string): RolePermissionEnum | null {
|
||||
if (this.isAdmin(session)) {
|
||||
return RolePermissionEnum.READWRITE;
|
||||
}
|
||||
const projectPermission = this.getProjectPermissionForAppId(session, appId);
|
||||
if (!projectPermission) {
|
||||
return null;
|
||||
}
|
||||
if (projectPermission?.roleAppPermissions.length > 0) {
|
||||
return (projectPermission.roleAppPermissions.find(app => app.appId === appId)?.permission ?? null) as RolePermissionEnum | null;
|
||||
}
|
||||
// If no roleAppPermissions are defined, we fallback to the projectPermission
|
||||
if (projectPermission.writeApps) {
|
||||
return RolePermissionEnum.READWRITE;
|
||||
}
|
||||
if (projectPermission.readApps) {
|
||||
return RolePermissionEnum.READ;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static sessionHasAccessToBackups(session: UserSession) {
|
||||
if (this.isAdmin(session)) {
|
||||
return true;
|
||||
}
|
||||
return !!session.userGroup?.canAccessBackups;
|
||||
}
|
||||
|
||||
static sessionCanCreateNewAppsForProject(session: UserSession, projectId: string) {
|
||||
if (this.isAdmin(session)) {
|
||||
return true;
|
||||
}
|
||||
const projectPermission = this.getProjectPermissionForProjectId(session, projectId);
|
||||
if (!projectPermission) {
|
||||
return false;
|
||||
}
|
||||
return !!projectPermission.createApps;
|
||||
}
|
||||
|
||||
static sessionCanDeleteAppsForProject(session: UserSession, projectId: string) {
|
||||
if (this.isAdmin(session)) {
|
||||
return true;
|
||||
}
|
||||
const projectPermission = this.getProjectPermissionForProjectId(session, projectId);
|
||||
if (!projectPermission) {
|
||||
return false;
|
||||
}
|
||||
return !!projectPermission.deleteApps;
|
||||
}
|
||||
|
||||
static sessionIsReadOnlyForApp(session: UserSession, appId: string) {
|
||||
if (this.isAdmin(session)) {
|
||||
return false;
|
||||
}
|
||||
const rolePermission = this.getRolePermissionForApp(session, appId);
|
||||
const roleHasReadAccessForApp = rolePermission === RolePermissionEnum.READ;
|
||||
const roleHasWriteAccessForApp = rolePermission === RolePermissionEnum.READWRITE;
|
||||
return !!roleHasReadAccessForApp && !roleHasWriteAccessForApp;
|
||||
}
|
||||
|
||||
static sessionHasReadAccessForApp(session: UserSession, appId: string) {
|
||||
if (this.isAdmin(session)) {
|
||||
return true;
|
||||
}
|
||||
const rolePermission = this.getRolePermissionForApp(session, appId);
|
||||
const roleHasReadAccessForApp = rolePermission === RolePermissionEnum.READ || rolePermission === RolePermissionEnum.READWRITE;
|
||||
return !!roleHasReadAccessForApp;
|
||||
}
|
||||
|
||||
static sessionHasWriteAccessForApp(session: UserSession, appId: string) {
|
||||
if (this.isAdmin(session)) {
|
||||
return true;
|
||||
}
|
||||
const rolePermission = this.getRolePermissionForApp(session, appId);
|
||||
const roleHasReadAccessForApp = rolePermission === RolePermissionEnum.READWRITE;
|
||||
return roleHasReadAccessForApp;
|
||||
}
|
||||
|
||||
static isAdmin(session: UserSession) {
|
||||
return session.userGroup?.name === adminRoleName;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user