Merge pull request #19 from biersoeckli/feature/multiuser-support

feat: multiuser support with users and roles
This commit is contained in:
Jan Meier
2025-03-24 11:53:19 +01:00
committed by GitHub
94 changed files with 2953 additions and 427 deletions

View 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");

View 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;

View 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");

View 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");

View File

@@ -0,0 +1,2 @@
DELETE FROM RoleProjectPermission;
DELETE FROM RoleAppPermission;

View 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;

View File

@@ -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

View 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);
});
});

View File

@@ -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;

View File

@@ -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}.`);

View File

@@ -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 {

View File

@@ -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(),

View File

@@ -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],

View File

@@ -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
View 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>
)
}

View File

@@ -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(

View File

@@ -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[]>>;

View File

@@ -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

View File

@@ -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);

View File

@@ -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>

View File

@@ -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>
)

View File

@@ -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);
});

View File

@@ -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');
});

View File

@@ -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()

View File

@@ -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 >
</>;
}

View File

@@ -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';

View File

@@ -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>
)

View File

@@ -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>>;

View File

@@ -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');
});

View File

@@ -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 >
</>;

View File

@@ -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>

View File

@@ -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,

View File

@@ -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 >

View File

@@ -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) {

View File

@@ -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 >

View File

@@ -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 >

View File

@@ -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>
);

View File

@@ -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.');

View File

@@ -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>
</>
}}

View File

@@ -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}>

View File

@@ -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>

View File

@@ -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}

View File

@@ -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);
}

View File

@@ -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 >
</>;
}

View File

@@ -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 >
</>;
}

View File

@@ -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 >
</>;
}

View File

@@ -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.");
});

View File

@@ -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>
)

View File

@@ -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>

View File

@@ -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.');
});

View File

@@ -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 (

View File

@@ -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);

View File

@@ -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');
});

View File

@@ -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">

View File

@@ -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">

View File

@@ -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);

View File

@@ -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);

View 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();
});

View 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>
)
}

View 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>
</>
)
}

View 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 >
</>
)
}

View 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>
</>;
}

View 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>
);
}

View 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}
/>
</>;
}

View File

@@ -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>

View File

@@ -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} />
}

View File

@@ -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>
)

View File

@@ -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} />
}

View File

@@ -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>
)
}

View File

@@ -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();

View File

@@ -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}`);

View File

@@ -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

View File

@@ -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('*******************************');

View 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;

View File

@@ -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();

View File

@@ -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>,

View File

@@ -4,6 +4,10 @@ export class Tags {
return `users`;
}
static roles() {
return `roles`;
}
static projects() {
return `projects`;
}

View File

@@ -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;
}

View File

@@ -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(),
}))

View File

@@ -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"

View File

@@ -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(),
}))

View 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(),
}))

View 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(),
}))

View 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(),
}))

View File

@@ -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(),

View 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(),
}))

View File

@@ -0,0 +1,5 @@
import { App, Project } from "@prisma/client";
export type ProjectExtendedModel = Project & {
apps: App[];
}

View 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>;

View 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";

View File

@@ -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[];
}[];
};

View 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>;

View 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;
};

View 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 "";
}
}

View 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;
}
}