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 // relationModel = false // Do not generate related model
modelCase = "PascalCase" // (default) Output models using pascal case (ex. UserModel, PostModel) 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 modelSuffix = "Model" // (default) Suffix to apply to your prisma models when naming Zod schemas
@@ -70,14 +69,18 @@ model Session {
} }
model User { model User {
id String @id @default(cuid()) id String @id @default(cuid())
name String? name String?
email String @unique email String @unique
emailVerified DateTime? emailVerified DateTime?
password String password String
twoFaSecret String? twoFaSecret String?
twoFaEnabled Boolean @default(false) twoFaEnabled Boolean @default(false)
image String? image String?
userGroupId String?
userGroup UserGroup? @relation(fields: [userGroupId], references: [id])
accounts Account[] accounts Account[]
sessions Session[] sessions Session[]
// Optional for WebAuthn support // Optional for WebAuthn support
@@ -114,10 +117,57 @@ model Authenticator {
// *** FROM HERE CUSTOM CLASSES // *** 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 { model Project {
id String @id @default(uuid()) id String @id @default(uuid())
name String name String
apps App[] apps App[]
roleProjectPermissions RoleProjectPermission[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -151,11 +201,12 @@ model App {
webhookId String? webhookId String?
appDomains AppDomain[] appDomains AppDomain[]
appPorts AppPort[] appPorts AppPort[]
appVolumes AppVolume[] appVolumes AppVolume[]
appFileMounts AppFileMount[] appFileMounts AppFileMount[]
appBasicAuths AppBasicAuth[] appBasicAuths AppBasicAuth[]
roleAppPermissions RoleAppPermission[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt 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 k3s from "@/server/adapter/kubernetes-api.adapter";
import appService from "@/server/services/app.service"; import appService from "@/server/services/app.service";
import deploymentService from "@/server/services/deployment.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 { Informer, V1Pod } from "@kubernetes/client-node";
import { z } from "zod"; import { z } from "zod";
import * as k8s from '@kubernetes/client-node'; import * as k8s from '@kubernetes/client-node';
@@ -18,6 +18,7 @@ export async function POST(request: Request) {
const input = await request.json(); const input = await request.json();
const podInfo = zodInputModel.parse(input); const podInfo = zodInputModel.parse(input);
let { appId } = podInfo; let { appId } = podInfo;
await isAuthorizedReadForApp(appId);
const app = await appService.getById(appId); const app = await appService.getById(appId);
const namespace = app.projectId; 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 { PathUtils } from "@/server/utils/path.utils";
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import fs from 'fs/promises'; 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 { ServiceException } from "@/shared/model/service.exception.model";
import { z } from "zod"; import { z } from "zod";
import { stringToDate } from "@/shared/utils/zod.utils"; import { stringToDate } from "@/shared/utils/zod.utils";
@@ -25,6 +25,8 @@ export async function GET(request: NextRequest) {
const date = requestUrl.searchParams.get('date'); const date = requestUrl.searchParams.get('date');
const validatedData = zodInputModel.parse({ appId, date }); const validatedData = zodInputModel.parse({ appId, date });
await isAuthorizedReadForApp(validatedData.appId);
const logsPath = PathUtils.appLogsFile(validatedData.appId, validatedData.date); const logsPath = PathUtils.appLogsFile(validatedData.appId, validatedData.date);
if (!await FsUtils.fileExists(logsPath)) { if (!await FsUtils.fileExists(logsPath)) {
throw new ServiceException(`Could not find logs for ${appId}.`); 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 { saveFormAction } from "@/server/utils/action-wrapper.utils";
import ipAddressFinderAdapter from "@/server/adapter/ip-adress-finder.adapter"; import ipAddressFinderAdapter from "@/server/adapter/ip-adress-finder.adapter";
import traefikMeDomainStandaloneService from "@/server/services/standalone-services/traefik-me-domain-standalone.service"; 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) => export const registerUser = async (prevState: any, inputData: RegisterFormInputSchema) =>
@@ -17,7 +18,8 @@ export const registerUser = async (prevState: any, inputData: RegisterFormInputS
if (allUsers.length !== 0) { if (allUsers.length !== 0) {
throw new ServiceException("User registration is currently not possible"); 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); await quickStackService.createOrUpdateCertIssuer(validatedData.email);
try { try {

View File

@@ -4,7 +4,7 @@ import monitoringService from "@/server/services/monitoring.service";
import clusterService from "@/server/services/node.service"; import clusterService from "@/server/services/node.service";
import pvcService from "@/server/services/pvc.service"; import pvcService from "@/server/services/pvc.service";
import backupService from "@/server/services/standalone-services/backup.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 { AppMonitoringUsageModel } from "@/shared/model/app-monitoring-usage.model";
import { AppVolumeMonitoringUsageModel } from "@/shared/model/app-volume-monitoring-usage.model"; import { AppVolumeMonitoringUsageModel } from "@/shared/model/app-volume-monitoring-usage.model";
import { NodeResourceModel } from "@/shared/model/node-resource.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) => export const downloadBackup = async (s3TargetId: string, s3Key: string) =>
simpleAction(async () => { simpleAction(async () => {
await getAuthUserSession(); await isAuthorizedForBackups();
const validatetData = z.object({ const validatetData = z.object({
s3TargetId: z.string(), s3TargetId: z.string(),
@@ -29,7 +29,7 @@ export const downloadBackup = async (s3TargetId: string, s3Key: string) =>
export const deleteBackup = async (s3TargetId: string, s3Key: string) => export const deleteBackup = async (s3TargetId: string, s3Key: string) =>
simpleAction(async () => { simpleAction(async () => {
await getAuthUserSession(); await isAuthorizedForBackups();
const validatetData = z.object({ const validatetData = z.object({
s3TargetId: z.string(), s3TargetId: z.string(),

View File

@@ -1,18 +1,14 @@
'use client' 'use client'
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { SimpleDataTable } from "@/components/custom/simple-data-table"; import { SimpleDataTable } from "@/components/custom/simple-data-table";
import { formatDateTime } from "@/frontend/utils/format.utils"; import { formatDateTime } from "@/frontend/utils/format.utils";
import { List } from "lucide-react"; import { List } from "lucide-react";
import { BackupInfoModel } from "@/shared/model/backup-info.model"; import { BackupInfoModel } from "@/shared/model/backup-info.model";
import { BackupDetailDialog } from "./backup-detail-overlay"; import { BackupDetailDialog } from "./backup-detail-overlay";
export default function BackupsTable({ data }: { data: BackupInfoModel[] }) { export default function BackupsTable({ data }: { data: BackupInfoModel[] }) {
return <> return <>
<SimpleDataTable columns={[ <SimpleDataTable columns={[
['projectId', 'Project ID', false], ['projectId', 'Project ID', false],

View File

@@ -1,6 +1,6 @@
'use server' '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 PageTitle from "@/components/custom/page-title";
import backupService from "@/server/services/standalone-services/backup.service"; import backupService from "@/server/services/standalone-services/backup.service";
import BackupsTable from "./backups-table"; import BackupsTable from "./backups-table";
@@ -14,7 +14,7 @@ import {
export default async function BackupsPage() { export default async function BackupsPage() {
await getAuthUserSession(); await isAuthorizedForBackups();
const { const {
backupInfoModels, backupInfoModels,
backupsVolumesWithoutActualBackups 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 { BreadcrumbsGenerator } from "../components/custom/breadcrumbs-generator";
import { getUserSession } from "@/server/utils/action-wrapper.utils"; import { getUserSession } from "@/server/utils/action-wrapper.utils";
import { InputDialog } from "@/components/custom/input-dialog"; import { InputDialog } from "@/components/custom/input-dialog";
import userGroupService from "@/server/services/user-group.service";
const inter = Inter({ const inter = Inter({
subsets: ["latin"], subsets: ["latin"],
@@ -40,6 +41,9 @@ export default async function RootLayout({
const session = await getUserSession(); const session = await getUserSession();
const userIsLoggedIn = !!session; const userIsLoggedIn = !!session;
// todo remove in future versions and handle migrations in an other way
await userGroupService.createDefaultRolesIfNotExists();
return ( return (
<html lang="en"> <html lang="en">
<body className={cn( <body className={cn(

View File

@@ -3,6 +3,7 @@
import monitoringService from "@/server/services/monitoring.service"; import monitoringService from "@/server/services/monitoring.service";
import clusterService from "@/server/services/node.service"; import clusterService from "@/server/services/node.service";
import { getAuthUserSession, simpleAction } from "@/server/utils/action-wrapper.utils"; 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 { AppMonitoringUsageModel } from "@/shared/model/app-monitoring-usage.model";
import { AppVolumeMonitoringUsageModel } from "@/shared/model/app-volume-monitoring-usage.model"; import { AppVolumeMonitoringUsageModel } from "@/shared/model/app-volume-monitoring-usage.model";
import { NodeResourceModel } from "@/shared/model/node-resource.model"; import { NodeResourceModel } from "@/shared/model/node-resource.model";
@@ -16,12 +17,16 @@ export const getNodeResourceUsage = async () =>
export const getVolumeMonitoringUsage = async () => export const getVolumeMonitoringUsage = async () =>
simpleAction(async () => { simpleAction(async () => {
await getAuthUserSession(); const session = await getAuthUserSession();
return await monitoringService.getAllAppVolumesUsage(); let volumesUsage = await monitoringService.getAllAppVolumesUsage();
volumesUsage = volumesUsage?.filter((volume) => UserGroupUtils.sessionHasReadAccessForApp(session, volume.appId));
return volumesUsage;
}) as Promise<ServerActionResult<unknown, AppVolumeMonitoringUsageModel[]>>; }) as Promise<ServerActionResult<unknown, AppVolumeMonitoringUsageModel[]>>;
export const getMonitoringForAllApps = async () => export const getMonitoringForAllApps = async () =>
simpleAction(async () => { simpleAction(async () => {
await getAuthUserSession(); const session = await getAuthUserSession();
return await monitoringService.getMonitoringForAllApps(); let updatedNodeRessources = await monitoringService.getMonitoringForAllApps();
updatedNodeRessources = updatedNodeRessources?.filter((app) => UserGroupUtils.sessionHasReadAccessForApp(session, app.appId));
return updatedNodeRessources;
}) as Promise<ServerActionResult<unknown, AppMonitoringUsageModel[]>>; }) as Promise<ServerActionResult<unknown, AppMonitoringUsageModel[]>>;

View File

@@ -10,10 +10,11 @@ import monitoringService from "@/server/services/monitoring.service";
import AppRessourceMonitoring from "./app-monitoring"; import AppRessourceMonitoring from "./app-monitoring";
import AppVolumeMonitoring from "./app-volumes-monitoring"; import AppVolumeMonitoring from "./app-volumes-monitoring";
import { AppMonitoringUsageModel } from "@/shared/model/app-monitoring-usage.model"; import { AppMonitoringUsageModel } from "@/shared/model/app-monitoring-usage.model";
import { UserGroupUtils } from "@/shared/utils/role.utils";
export default async function ResourceNodesInfoPage() { export default async function ResourceNodesInfoPage() {
await getAuthUserSession(); const session = await getAuthUserSession();
let resourcesNode: NodeResourceModel[] | undefined; let resourcesNode: NodeResourceModel[] | undefined;
let volumesUsage: AppVolumeMonitoringUsageModel[] | undefined; let volumesUsage: AppVolumeMonitoringUsageModel[] | undefined;
let updatedNodeRessources: AppMonitoringUsageModel[] | 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 // 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 ( return (
<div className="flex-1 space-y-4 pt-6"> <div className="flex-1 space-y-4 pt-6">
<PageTitle <PageTitle

View File

@@ -2,7 +2,7 @@
import { 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 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 { z } from "zod";
import appTemplateService from "@/server/services/app-template.service"; import appTemplateService from "@/server/services/app-template.service";
import { AppTemplateModel, appTemplateZodModel } from "@/shared/model/app-template.model"; 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 fileBrowserService from "@/server/services/file-browser-service";
import phpMyAdminService from "@/server/services/db-tool-services/phpmyadmin.service"; import phpMyAdminService from "@/server/services/db-tool-services/phpmyadmin.service";
import pgAdminService from "@/server/services/db-tool-services/pgadmin.service"; import pgAdminService from "@/server/services/db-tool-services/pgadmin.service";
import { UserGroupUtils } from "@/shared/utils/role.utils";
const createAppSchema = z.object({ const createAppSchema = z.object({
appName: z.string().min(1) appName: z.string().min(1)
@@ -18,7 +19,10 @@ const createAppSchema = z.object({
export const createApp = async (appName: string, projectId: string, appId?: string) => export const createApp = async (appName: string, projectId: string, appId?: string) =>
saveFormAction({ appName }, createAppSchema, async (validatedData) => { 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({ const returnData = await appService.save({
id: appId ?? undefined, 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) => export const createAppFromTemplate = async (prevState: any, inputData: AppTemplateModel, projectId: string) =>
saveFormAction(inputData, appTemplateZodModel, async (validatedData) => { 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))) { if (validatedData.templates.some(x => x.inputSettings.some(y => !y.randomGeneratedIfEmpty && !y.value))) {
throw new ServiceException('Please fill out all required fields.'); 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) => export const deleteApp = async (appId: string) =>
simpleAction(async () => { simpleAction(async () => {
await getAuthUserSession(); const session = await getAuthUserSession();
const app = await appService.getExtendedById(appId); 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 // First delete external services wich might be running
await dbGateService.deleteToolForAppIfExists(appId); await dbGateService.deleteToolForAppIfExists(appId);
await phpMyAdminService.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 { useBreadcrumbs, useConfirmDialog } from "@/frontend/states/zustand.states";
import { useEffect } from "react"; import { useEffect } from "react";
import { EditAppDialog } from "./edit-app-dialog"; 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({
export default function AppTable({ app, projectId }: { app: App[], projectId: string }) { app,
projectId,
session
}: {
app: App[],
projectId: string,
session: UserSession
}) {
const { openConfirmDialog: openDialog } = useConfirmDialog(); const { openConfirmDialog: openDialog } = useConfirmDialog();
@@ -54,18 +63,19 @@ export default function AppTable({ app, projectId }: { app: App[], projectId: st
</DropdownMenuItem> </DropdownMenuItem>
</Link> </Link>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<EditAppDialog projectId={projectId} existingItem={item}> {UserGroupUtils.sessionCanCreateNewAppsForProject(session, projectId) &&
<DropdownMenuItem> <EditAppDialog projectId={projectId} existingItem={item}>
<Edit2 /> <span>Edit App Name</span> <DropdownMenuItem>
</DropdownMenuItem> <Edit2 /> <span>Edit App Name</span>
</EditAppDialog> </DropdownMenuItem>
<DropdownMenuItem className="text-red-500" </EditAppDialog>}
{UserGroupUtils.sessionCanDeleteAppsForProject(session, projectId) && <DropdownMenuItem className="text-red-500"
onClick={() => openDialog({ onClick={() => openDialog({
title: "Delete App", title: "Delete App",
description: "Are you sure you want to delete this app? All data will be lost and this action cannot be undone.", 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)}> }).then((result) => result ? Toast.fromAction(() => deleteApp(item.id)) : undefined)}>
<Trash /> <span >Delete App</span> <Trash /> <span >Delete App</span>
</DropdownMenuItem> </DropdownMenuItem>}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div> </div>

View File

@@ -8,6 +8,7 @@ import appService from "@/server/services/app.service";
import PageTitle from "@/components/custom/page-title"; import PageTitle from "@/components/custom/page-title";
import ProjectBreadcrumbs from "./project-breadcrumbs"; import ProjectBreadcrumbs from "./project-breadcrumbs";
import CreateProjectActions from "./create-project-actions"; import CreateProjectActions from "./create-project-actions";
import { UserGroupUtils } from "@/shared/utils/role.utils";
export default async function AppsPage({ export default async function AppsPage({
searchParams, searchParams,
@@ -16,7 +17,7 @@ export default async function AppsPage({
searchParams?: { [key: string]: string | undefined }; searchParams?: { [key: string]: string | undefined };
params: { projectId: string } params: { projectId: string }
}) { }) {
await getAuthUserSession(); const session = await getAuthUserSession();
const projectId = params?.projectId; const projectId = params?.projectId;
if (!projectId) { if (!projectId) {
@@ -24,14 +25,18 @@ export default async function AppsPage({
} }
const project = await projectService.getById(projectId); const project = await projectService.getById(projectId);
const data = await appService.getAllAppsByProjectID(projectId); const data = await appService.getAllAppsByProjectID(projectId);
const relevantApps = data.filter((app) =>
UserGroupUtils.sessionHasReadAccessForApp(session, app.id));
return ( return (
<div className="flex-1 space-y-4 pt-6"> <div className="flex-1 space-y-4 pt-6">
<PageTitle <PageTitle
title="Apps" title="Apps"
subtitle={`All Apps for Project "${project.name}"`}> subtitle={`All Apps for Project "${project.name}"`}>
<CreateProjectActions projectId={projectId} /> {UserGroupUtils.sessionCanCreateNewAppsForProject(session, params.projectId) &&
<CreateProjectActions projectId={projectId} />}
</PageTitle> </PageTitle>
<AppTable app={data} projectId={project.id} /> <AppTable session={session} app={relevantApps} projectId={project.id} />
<ProjectBreadcrumbs project={project} /> <ProjectBreadcrumbs project={project} />
</div> </div>
) )

View File

@@ -3,20 +3,20 @@
import { 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 appService from "@/server/services/app.service";
import deploymentService from "@/server/services/deployment.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"; import eventService from "@/server/services/event.service";
export const deploy = async (appId: string, forceBuild = false) => export const deploy = async (appId: string, forceBuild = false) =>
simpleAction(async () => { simpleAction(async () => {
await getAuthUserSession(); await isAuthorizedWriteForApp(appId);
await appService.buildAndDeploy(appId, forceBuild); await appService.buildAndDeploy(appId, forceBuild);
return new SuccessActionResult(undefined, 'Successfully started deployment.'); return new SuccessActionResult(undefined, 'Successfully started deployment.');
}); });
export const stopApp = async (appId: string) => export const stopApp = async (appId: string) =>
simpleAction(async () => { simpleAction(async () => {
await getAuthUserSession(); await isAuthorizedWriteForApp(appId);
const app = await appService.getExtendedById(appId); const app = await appService.getExtendedById(appId);
await deploymentService.setReplicasForDeployment(app.projectId, app.id, 0); await deploymentService.setReplicasForDeployment(app.projectId, app.id, 0);
return new SuccessActionResult(undefined, 'Successfully stopped app.'); return new SuccessActionResult(undefined, 'Successfully stopped app.');
@@ -24,7 +24,7 @@ export const stopApp = async (appId: string) =>
export const startApp = async (appId: string) => export const startApp = async (appId: string) =>
simpleAction(async () => { simpleAction(async () => {
await getAuthUserSession(); await isAuthorizedWriteForApp(appId);
const app = await appService.getExtendedById(appId); const app = await appService.getExtendedById(appId);
await deploymentService.setReplicasForDeployment(app.projectId, app.id, app.replicas); await deploymentService.setReplicasForDeployment(app.projectId, app.id, app.replicas);
return new SuccessActionResult(undefined, 'Successfully started app.'); return new SuccessActionResult(undefined, 'Successfully started app.');
@@ -32,7 +32,7 @@ export const startApp = async (appId: string) =>
export const getLatestAppEvents = async (appId: string) => export const getLatestAppEvents = async (appId: string) =>
simpleAction(async () => { simpleAction(async () => {
await getAuthUserSession(); await isAuthorizedReadForApp(appId);
const app = await appService.getById(appId); const app = await appService.getById(appId);
return await eventService.getEventsForApp(app.projectId, app.id); return await eventService.getEventsForApp(app.projectId, app.id);
}); });

View File

@@ -1,24 +1,14 @@
'use server' 'use server'
import { appVolumeEditZodModel } from "@/shared/model/volume-edit.model"; import { SuccessActionResult } from "@/shared/model/server-action-error-return.model";
import { ServerActionResult, SuccessActionResult } from "@/shared/model/server-action-error-return.model";
import appService from "@/server/services/app.service"; import appService from "@/server/services/app.service";
import { getAuthUserSession, saveFormAction, simpleAction } from "@/server/utils/action-wrapper.utils"; import { 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";
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 { BasicAuthEditModel, basicAuthEditZodModel } from "@/shared/model/basic-auth-edit.model"; import { BasicAuthEditModel, basicAuthEditZodModel } from "@/shared/model/basic-auth-edit.model";
export const saveBasicAuth = async (prevState: any, inputData: BasicAuthEditModel) => export const saveBasicAuth = async (prevState: any, inputData: BasicAuthEditModel) =>
saveFormAction(inputData, basicAuthEditZodModel, async (validatedData) => { saveFormAction(inputData, basicAuthEditZodModel, async (validatedData) => {
await getAuthUserSession(); await isAuthorizedWriteForApp(validatedData.appId);
await appService.saveBasicAuth({ await appService.saveBasicAuth({
...validatedData, ...validatedData,
@@ -30,7 +20,7 @@ export const saveBasicAuth = async (prevState: any, inputData: BasicAuthEditMode
export const deleteBasicAuth = async (basicAuthId: string) => export const deleteBasicAuth = async (basicAuthId: string) =>
simpleAction(async () => { simpleAction(async () => {
await getAuthUserSession(); await isAuthorizedWriteForApp(await appService.getBasicAuthById(basicAuthId).then(b => b.appId));
await appService.deleteBasicAuthById(basicAuthId); await appService.deleteBasicAuthById(basicAuthId);
return new SuccessActionResult(undefined, 'Successfully deleted item'); return new SuccessActionResult(undefined, 'Successfully deleted item');
}); });

View File

@@ -43,7 +43,6 @@ export default function BasicAuthEditDialog({
const [isOpen, setIsOpen] = useState<boolean>(false); const [isOpen, setIsOpen] = useState<boolean>(false);
const form = useForm<BasicAuthEditModel>({ const form = useForm<BasicAuthEditModel>({
resolver: zodResolver(basicAuthEditZodModel.merge(z.object({ resolver: zodResolver(basicAuthEditZodModel.merge(z.object({
appId: z.string().nullish() appId: z.string().nullish()

View File

@@ -13,8 +13,9 @@ import BasicAuthEditDialog from "./basic-auth-edit-dialog";
import { deleteBasicAuth } from "./actions"; import { deleteBasicAuth } from "./actions";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
export default function BasicAuth({ app }: { export default function BasicAuth({ app, readonly }: {
app: AppExtendedModel app: AppExtendedModel;
readonly: boolean;
}) { }) {
const { openConfirmDialog: openDialog } = useConfirmDialog(); const { openConfirmDialog: openDialog } = useConfirmDialog();
@@ -64,24 +65,24 @@ export default function BasicAuth({ app }: {
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
</TableCell> </TableCell>
<TableCell className="font-medium flex gap-2"> {!readonly && <TableCell className="font-medium flex gap-2">
<BasicAuthEditDialog app={app} basicAuth={basicAuth}> <BasicAuthEditDialog app={app} basicAuth={basicAuth}>
<Button variant="ghost"><EditIcon /></Button> <Button variant="ghost"><EditIcon /></Button>
</BasicAuthEditDialog> </BasicAuthEditDialog>
<Button variant="ghost" onClick={() => asyncDelete(basicAuth.id)}> <Button variant="ghost" onClick={() => asyncDelete(basicAuth.id)}>
<TrashIcon /> <TrashIcon />
</Button> </Button>
</TableCell> </TableCell>}
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
</Table> </Table>
</CardContent> </CardContent>
<CardFooter> {!readonly && <CardFooter>
<FileMountEditDialog app={app}> <FileMountEditDialog app={app}>
<Button>Add Auth Credential</Button> <Button>Add Auth Credential</Button>
</FileMountEditDialog> </FileMountEditDialog>
</CardFooter> </CardFooter>}
</Card > </Card >
</>; </>;
} }

View File

@@ -7,24 +7,29 @@ import { AppExtendedModel } from "@/shared/model/app-extended.model";
import { Toast } from "@/frontend/utils/toast.utils"; import { Toast } from "@/frontend/utils/toast.utils";
import AppStatus from "./app-status"; import AppStatus from "./app-status";
import { ExternalLink, Hammer, Pause, Play, Rocket } from "lucide-react"; import { ExternalLink, Hammer, Pause, Play, Rocket } from "lucide-react";
import { toast } from "sonner";
import { AppEventsDialog } from "./app-events-dialog"; import { AppEventsDialog } from "./app-events-dialog";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; 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({ export default function AppActionButtons({
app app,
session
}: { }: {
app: AppExtendedModel; app: AppExtendedModel;
session: UserSession;
}) { }) {
const hasWriteAccess = UserGroupUtils.sessionHasWriteAccessForApp(session, app.id);
return <Card> return <Card>
<CardContent className="p-4 "> <CardContent className="p-4 ">
<ScrollArea> <ScrollArea>
<div className="flex gap-4"> <div className="flex gap-4">
<div className="self-center"><AppEventsDialog app={app}><AppStatus appId={app.id} /></AppEventsDialog></div> <div className="self-center"><AppEventsDialog app={app}><AppStatus appId={app.id} /></AppEventsDialog></div>
<Button onClick={() => Toast.fromAction(() => deploy(app.id))}><Rocket /> Deploy</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(() => 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(() => startApp(app.id))} variant="secondary"><Play />Start</Button>
<Button onClick={() => Toast.fromAction(() => stopApp(app.id))} variant="secondary"><Pause /> Stop</Button> <Button onClick={() => Toast.fromAction(() => stopApp(app.id))} variant="secondary"><Pause /> Stop</Button>
</>}
{app.appDomains.length > 0 && <Button onClick={() => { {app.appDomains.length > 0 && <Button onClick={() => {
const domain = app.appDomains[0]; const domain = app.appDomains[0];
const protocol = domain.useSsl ? 'https' : 'http'; 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 BasicAuth from "./advanced/basic-auth";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import DbToolsCard from "./credentials/db-tools"; import DbToolsCard from "./credentials/db-tools";
import { RolePermissionEnum } from "@/shared/model/role-extended.model.ts";
export default function AppTabs({ export default function AppTabs({
app, app,
role,
tabName, tabName,
s3Targets, s3Targets,
volumeBackups volumeBackups
}: { }: {
app: AppExtendedModel; app: AppExtendedModel;
role: RolePermissionEnum;
tabName: string; tabName: string;
s3Targets: S3Target[], s3Targets: S3Target[],
volumeBackups: VolumeBackupExtendedModel[] volumeBackups: VolumeBackupExtendedModel[]
}) { }) {
const router = useRouter(); const router = useRouter();
const readonly = role !== RolePermissionEnum.READWRITE;
const openTab = (tabName: string) => { const openTab = (tabName: string) => {
router.push(`/project/app/${app.id}?tabName=${tabName}`); router.push(`/project/app/${app.id}?tabName=${tabName}`);
} }
return ( return (
<Tabs defaultValue="general" value={tabName} onValueChange={(newTab) => openTab(newTab)} className="space-y-4"> <Tabs defaultValue="general" value={tabName} onValueChange={(newTab) => openTab(newTab)} className="space-y-4">
<ScrollArea > <ScrollArea>
<TabsList> <TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger> <TabsTrigger value="overview">Overview</TabsTrigger>
{app.appType !== 'APP' && <TabsTrigger value="credentials">Credentials</TabsTrigger>} {app.appType !== 'APP' && <TabsTrigger value="credentials">Credentials</TabsTrigger>}
<TabsTrigger value="general">General</TabsTrigger> <TabsTrigger value="general">General</TabsTrigger>
@@ -56,35 +58,36 @@ export default function AppTabs({
</ScrollArea> </ScrollArea>
<TabsContent value="overview" className="grid grid-cols-1 3xl:grid-cols-2 gap-4"> <TabsContent value="overview" className="grid grid-cols-1 3xl:grid-cols-2 gap-4">
<MonitoringTab app={app} /> <MonitoringTab app={app} />
<Logs app={app} /> <Logs role={role} app={app} />
<BuildsTab app={app} /> <BuildsTab role={role} app={app} />
<WebhookDeploymentInfo app={app} /> <WebhookDeploymentInfo role={role} app={app} />
</TabsContent> </TabsContent>
{app.appType !== 'APP' && <TabsContent value="credentials" className="space-y-4"> {app.appType !== 'APP' && <TabsContent value="credentials" className="space-y-4">
<DbToolsCard app={app} /> {role === RolePermissionEnum.READWRITE && <DbToolsCard app={app} />}
<DbCredentials app={app} /> <DbCredentials app={app} />
</TabsContent>} </TabsContent>}
<TabsContent value="general" className="space-y-4"> <TabsContent value="general" className="space-y-4">
<GeneralAppSource app={app} /> <GeneralAppSource readonly={readonly} app={app} />
<GeneralAppRateLimits app={app} /> <GeneralAppRateLimits readonly={readonly} app={app} />
</TabsContent> </TabsContent>
<TabsContent value="environment" className="space-y-4"> <TabsContent value="environment" className="space-y-4">
<EnvEdit app={app} /> <EnvEdit readonly={readonly} app={app} />
</TabsContent> </TabsContent>
<TabsContent value="domains" className="space-y-4"> <TabsContent value="domains" className="space-y-4">
<DomainsList app={app} /> <DomainsList readonly={readonly} app={app} />
<InternalHostnames app={app} /> <InternalHostnames readonly={readonly} app={app} />
</TabsContent> </TabsContent>
<TabsContent value="storage" className="space-y-4"> <TabsContent value="storage" className="space-y-4">
<StorageList app={app} /> <StorageList readonly={readonly} app={app} />
<FileMount app={app} /> <FileMount readonly={readonly} app={app} />
<VolumeBackupList <VolumeBackupList
readonly={readonly}
app={app} app={app}
s3Targets={s3Targets} s3Targets={s3Targets}
volumeBackups={volumeBackups} /> volumeBackups={volumeBackups} />
</TabsContent> </TabsContent>
<TabsContent value="advanced" className="space-y-4"> <TabsContent value="advanced" className="space-y-4">
<BasicAuth app={app} /> <BasicAuth readonly={readonly} app={app} />
</TabsContent> </TabsContent>
</Tabs> </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 dbGateService from "@/server/services/db-tool-services/dbgate.service";
import pgAdminService from "@/server/services/db-tool-services/pgadmin.service"; import pgAdminService from "@/server/services/db-tool-services/pgadmin.service";
import phpMyAdminService from "@/server/services/db-tool-services/phpmyadmin.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 { AppTemplateUtils } from "@/server/utils/app-template.utils";
import { DatabaseTemplateInfoModel } from "@/shared/model/database-template-info.model"; import { DatabaseTemplateInfoModel } from "@/shared/model/database-template-info.model";
import { ServerActionResult, SuccessActionResult } from "@/shared/model/server-action-error-return.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) => export const getDatabaseCredentials = async (appId: string) =>
simpleAction(async () => { simpleAction(async () => {
await getAuthUserSession(); await isAuthorizedReadForApp(appId);
const app = await appService.getExtendedById(appId); const app = await appService.getExtendedById(appId);
const credentials = AppTemplateUtils.getDatabaseModelFromApp(app); const credentials = AppTemplateUtils.getDatabaseModelFromApp(app);
return new SuccessActionResult(credentials); return new SuccessActionResult(credentials);
@@ -28,7 +28,7 @@ export const getDatabaseCredentials = async (appId: string) =>
export const getIsDbToolActive = async (appId: string, dbTool: DbToolIds) => export const getIsDbToolActive = async (appId: string, dbTool: DbToolIds) =>
simpleAction(async () => { simpleAction(async () => {
await getAuthUserSession(); await isAuthorizedWriteForApp(appId);
if (!dbToolClasses.has(dbTool)) { if (!dbToolClasses.has(dbTool)) {
throw new ServiceException('Unknown db tool'); 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) => export const deployDbTool = async (appId: string, dbTool: DbToolIds) =>
simpleAction(async () => { simpleAction(async () => {
await getAuthUserSession(); await isAuthorizedWriteForApp(appId);
const currentDbTool = dbToolClasses.get(dbTool); const currentDbTool = dbToolClasses.get(dbTool);
if (!currentDbTool) { if (!currentDbTool) {
@@ -51,7 +51,7 @@ export const deployDbTool = async (appId: string, dbTool: DbToolIds) =>
export const getLoginCredentialsForRunningDbTool = async (appId: string, dbTool: DbToolIds) => export const getLoginCredentialsForRunningDbTool = async (appId: string, dbTool: DbToolIds) =>
simpleAction(async () => { simpleAction(async () => {
await getAuthUserSession(); await isAuthorizedWriteForApp(appId);
const currentDbTool = dbToolClasses.get(dbTool); const currentDbTool = dbToolClasses.get(dbTool);
if (!currentDbTool) { if (!currentDbTool) {
@@ -63,7 +63,7 @@ export const getLoginCredentialsForRunningDbTool = async (appId: string, dbTool:
export const deleteDbToolDeploymentForAppIfExists = async (appId: string, dbTool: DbToolIds) => export const deleteDbToolDeploymentForAppIfExists = async (appId: string, dbTool: DbToolIds) =>
simpleAction(async () => { simpleAction(async () => {
await getAuthUserSession(); await isAuthorizedWriteForApp(appId);
const currentDbTool = dbToolClasses.get(dbTool); const currentDbTool = dbToolClasses.get(dbTool);
if (!currentDbTool) { if (!currentDbTool) {
@@ -76,7 +76,7 @@ export const deleteDbToolDeploymentForAppIfExists = async (appId: string, dbTool
export const downloadDbGateFilesForApp = async (appId: string) => export const downloadDbGateFilesForApp = async (appId: string) =>
simpleAction(async () => { simpleAction(async () => {
await getAuthUserSession(); await isAuthorizedWriteForApp(appId);
const url = await dbGateService.downloadDbGateFilesForApp(appId); const url = await dbGateService.downloadDbGateFilesForApp(appId);
return new SuccessActionResult(url); return new SuccessActionResult(url);
}) as Promise<ServerActionResult<unknown, string>>; }) 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 { appDomainEditZodModel } from "@/shared/model/domain-edit.model";
import { 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 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 { z } from "zod";
import { TraefikMeUtils } from "@/shared/utils/traefik-me.utils"; import { TraefikMeUtils } from "@/shared/utils/traefik-me.utils";
import { ServiceException } from "@/shared/model/service.exception.model"; 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>) => export const saveDomain = async (prevState: any, inputData: z.infer<typeof actionAppDomainEditZodModel>) =>
saveFormAction(inputData, actionAppDomainEditZodModel, async (validatedData) => { saveFormAction(inputData, actionAppDomainEditZodModel, async (validatedData) => {
await getAuthUserSession(); await isAuthorizedWriteForApp(validatedData.appId);
if (validatedData.hostname.includes('://')) { if (validatedData.hostname.includes('://')) {
const url = new URL(validatedData.hostname); 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) => export const deleteDomain = async (domainId: string) =>
simpleAction(async () => { simpleAction(async () => {
await getAuthUserSession(); await isAuthorizedWriteForApp(await appService.getDomainById(domainId).then(d => d.appId));
await appService.deleteDomainById(domainId); await appService.deleteDomainById(domainId);
return new SuccessActionResult(undefined, 'Successfully deleted domain'); return new SuccessActionResult(undefined, 'Successfully deleted domain');
}); });
export const savePort = async (prevState: any, inputData: AppPortModel, appId: string, portId?: string) => export const savePort = async (prevState: any, inputData: AppPortModel, appId: string, portId?: string) =>
saveFormAction(inputData, appPortZodModel, async (validatedData) => { saveFormAction(inputData, appPortZodModel, async (validatedData) => {
await getAuthUserSession(); await isAuthorizedWriteForApp(appId);
await appService.savePort({ await appService.savePort({
...validatedData, ...validatedData,
id: portId ?? undefined, id: portId ?? undefined,
@@ -54,7 +54,7 @@ export const savePort = async (prevState: any, inputData: AppPortModel, appId: s
export const deletePort = async (portId: string) => export const deletePort = async (portId: string) =>
simpleAction(async () => { simpleAction(async () => {
await getAuthUserSession(); await isAuthorizedWriteForApp(await appService.getPortById(portId).then(p => p.appId));
await appService.deletePortById(portId); await appService.deletePortById(portId);
return new SuccessActionResult(undefined, 'Successfully deleted port'); 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"; import { useConfirmDialog } from "@/frontend/states/zustand.states";
export default function DomainsList({ app }: { export default function DomainsList({ app, readonly }: {
app: AppExtendedModel app: AppExtendedModel;
readonly: boolean;
}) { }) {
const { openConfirmDialog: openDialog } = useConfirmDialog(); 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.port}</TableCell>
<TableCell className="font-medium">{domain.useSsl ? <CheckIcon /> : <XIcon />}</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">{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}> <DialogEditDialog appId={app.id} domain={domain}>
<Button variant="ghost"><EditIcon /></Button> <Button variant="ghost"><EditIcon /></Button>
</DialogEditDialog> </DialogEditDialog>
<Button variant="ghost" onClick={() => asyncDeleteDomain(domain.id)}> <Button variant="ghost" onClick={() => asyncDeleteDomain(domain.id)}>
<TrashIcon /> <TrashIcon />
</Button> </Button>
</TableCell> </TableCell>}
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
</Table> </Table>
</CardContent> </CardContent>
<CardFooter> {!readonly && <CardFooter>
<DialogEditDialog appId={app.id}> <DialogEditDialog appId={app.id}>
<Button><Plus /> Add Domain</Button> <Button><Plus /> Add Domain</Button>
</DialogEditDialog> </DialogEditDialog>
</CardFooter> </CardFooter>}
</Card > </Card >
</>; </>;

View File

@@ -15,8 +15,9 @@ import { EditIcon, Plus, TrashIcon } from "lucide-react";
import { Toast } from "@/frontend/utils/toast.utils"; import { Toast } from "@/frontend/utils/toast.utils";
import { useConfirmDialog } from "@/frontend/states/zustand.states"; import { useConfirmDialog } from "@/frontend/states/zustand.states";
export default function InternalHostnames({ app }: { export default function InternalHostnames({ app, readonly }: {
app: AppExtendedModel app: AppExtendedModel;
readonly: boolean;
}) { }) {
const { openConfirmDialog: openDialog } = useConfirmDialog(); const { openConfirmDialog: openDialog } = useConfirmDialog();
@@ -34,7 +35,6 @@ export default function InternalHostnames({ app }: {
const internalUrl = KubeObjectNameUtils.toServiceName(app.id); const internalUrl = KubeObjectNameUtils.toServiceName(app.id);
return <> return <>
<Card> <Card>
<CardHeader> <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> <CardDescription>If you want to connect other apps to this app, you have to configure the internal ports below.</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<Table> <Table>
<TableCaption>{app.appPorts.length} Ports</TableCaption> <TableCaption>{app.appPorts.length} Ports</TableCaption>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Port</TableHead> <TableHead>Port</TableHead>
<TableHead className="w-[100px]">Action</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>
</TableRow> </TableRow>
))} </TableHeader>
</TableBody> <TableBody>
</Table> {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> </CardContent>
<CardFooter> {!readonly && <CardFooter>
<DefaultPortEditDialog appId={app.id}> <DefaultPortEditDialog appId={app.id}>
<Button><Plus /> Add Port</Button> <Button><Plus /> Add Port</Button>
</DefaultPortEditDialog> </DefaultPortEditDialog>
</CardFooter> </CardFooter>}
</Card> </Card>
<Card> <Card>

View File

@@ -2,12 +2,12 @@
import { AppEnvVariablesModel, appEnvVariablesZodModel } from "@/shared/model/env-edit.model"; import { AppEnvVariablesModel, appEnvVariablesZodModel } from "@/shared/model/env-edit.model";
import appService from "@/server/services/app.service"; 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) => export const saveEnvVariables = async (prevState: any, inputData: AppEnvVariablesModel, appId: string) =>
saveFormAction(inputData, appEnvVariablesZodModel, async (validatedData) => { saveFormAction(inputData, appEnvVariablesZodModel, async (validatedData) => {
await getAuthUserSession(); await isAuthorizedWriteForApp(appId);
const existingApp = await appService.getById(appId); const existingApp = await appService.getById(appId);
await appService.save({ await appService.save({
...existingApp, ...existingApp,

View File

@@ -16,12 +16,14 @@ import { Textarea } from "@/components/ui/textarea";
import { AppExtendedModel } from "@/shared/model/app-extended.model"; import { AppExtendedModel } from "@/shared/model/app-extended.model";
export default function EnvEdit({ app }: { export default function EnvEdit({ app, readonly }: {
app: AppExtendedModel app: AppExtendedModel;
readonly: boolean;
}) { }) {
const form = useForm<AppEnvVariablesModel>({ const form = useForm<AppEnvVariablesModel>({
resolver: zodResolver(appEnvVariablesZodModel), 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>()); 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> </CardContent>
<CardFooter> {!readonly && <CardFooter>
<SubmitButton>Save</SubmitButton> <SubmitButton>Save</SubmitButton>
</CardFooter> </CardFooter>}
</form> </form>
</Form > </Form >
</Card > </Card >

View File

@@ -7,13 +7,13 @@ import { ErrorActionResult, ServerActionResult, SuccessActionResult } from "@/sh
import { ServiceException } from "@/shared/model/service.exception.model"; import { ServiceException } from "@/shared/model/service.exception.model";
import appService from "@/server/services/app.service"; import appService from "@/server/services/app.service";
import userService from "@/server/services/user.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) => { export const saveGeneralAppSourceInfo = async (prevState: any, inputData: AppSourceInfoInputModel, appId: string) => {
if (inputData.sourceType === 'GIT') { if (inputData.sourceType === 'GIT') {
return saveFormAction(inputData, appSourceInfoGitZodModel, async (validatedData) => { return saveFormAction(inputData, appSourceInfoGitZodModel, async (validatedData) => {
await getAuthUserSession(); await isAuthorizedWriteForApp(appId);
const existingApp = await appService.getById(appId); const existingApp = await appService.getById(appId);
await appService.save({ await appService.save({
...existingApp, ...existingApp,
@@ -24,7 +24,7 @@ export const saveGeneralAppSourceInfo = async (prevState: any, inputData: AppSou
}); });
} else if (inputData.sourceType === 'CONTAINER') { } else if (inputData.sourceType === 'CONTAINER') {
return saveFormAction(inputData, appSourceInfoContainerZodModel, async (validatedData) => { return saveFormAction(inputData, appSourceInfoContainerZodModel, async (validatedData) => {
await getAuthUserSession(); await isAuthorizedWriteForApp(appId);
const existingApp = await appService.getById(appId); const existingApp = await appService.getById(appId);
await appService.save({ await appService.save({
...existingApp, ...existingApp,
@@ -43,7 +43,7 @@ export const saveGeneralAppRateLimits = async (prevState: any, inputData: AppRat
if (validatedData.replicas < 1) { if (validatedData.replicas < 1) {
throw new ServiceException('Replica Count must be at least 1'); throw new ServiceException('Replica Count must be at least 1');
} }
await getAuthUserSession(); await isAuthorizedWriteForApp(appId);
const extendedApp = await appService.getExtendedById(appId); const extendedApp = await appService.getExtendedById(appId);
if (extendedApp.appVolumes.some(v => v.accessMode === 'ReadWriteOnce') && validatedData.replicas > 1) { 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"; import { cn } from "@/frontend/utils/utils";
export default function GeneralAppRateLimits({ app }: { export default function GeneralAppRateLimits({ app, readonly }: {
app: AppExtendedModel app: AppExtendedModel;
readonly: boolean;
}) { }) {
const form = useForm<AppRateLimitsModel>({ const form = useForm<AppRateLimitsModel>({
resolver: zodResolver(appRateLimitsZodModel), 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>()); 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> </div>
</CardContent> </CardContent>
<CardFooter className="gap-4"> {!readonly && <CardFooter className="gap-4">
<SubmitButton>Save</SubmitButton> <SubmitButton>Save</SubmitButton>
<p className="text-red-500">{state?.message}</p> <p className="text-red-500">{state?.message}</p>
</CardFooter> </CardFooter>}
</form> </form>
</Form > </Form >
</Card > </Card >

View File

@@ -18,15 +18,17 @@ import { App } from "@prisma/client";
import { toast } from "sonner"; import { toast } from "sonner";
import { AppExtendedModel } from "@/shared/model/app-extended.model"; import { AppExtendedModel } from "@/shared/model/app-extended.model";
export default function GeneralAppSource({ app }: { export default function GeneralAppSource({ app, readonly }: {
app: AppExtendedModel app: AppExtendedModel;
readonly: boolean;
}) { }) {
const form = useForm<AppSourceInfoInputModel>({ const form = useForm<AppSourceInfoInputModel>({
resolver: zodResolver(appSourceInfoInputZodModel), resolver: zodResolver(appSourceInfoInputZodModel),
defaultValues: { defaultValues: {
...app, ...app,
sourceType: app.sourceType as 'GIT' | 'CONTAINER' 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>()); 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> </TabsContent>
</Tabs> </Tabs>
</CardContent> </CardContent>
<CardFooter className="gap-4"> {!readonly && <CardFooter className="gap-4">
<SubmitButton>Save</SubmitButton> <SubmitButton>Save</SubmitButton>
<p className="text-red-500">{state?.message}</p> <p className="text-red-500">{state?.message}</p>
</CardFooter> </CardFooter>}
</form> </form>
</Form > </Form >
</Card > </Card >

View File

@@ -1,5 +1,5 @@
import { Inter } from "next/font/google"; 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 appService from "@/server/services/app.service";
import { import {
Breadcrumb, Breadcrumb,
@@ -19,11 +19,11 @@ export default async function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
await getAuthUserSession();
const appId = params?.appId; const appId = params?.appId;
if (!appId) { if (!appId) {
return <p>Could not find app with id {appId}</p> return <p>Could not find app with id {appId}</p>
} }
const session = await isAuthorizedReadForApp(appId);
const app = await appService.getExtendedById(appId); const app = await appService.getExtendedById(appId);
return ( return (
@@ -32,7 +32,7 @@ export default async function RootLayout({
title={app.name} title={app.name}
subtitle={`App ID: ${app.id}`}> subtitle={`App ID: ${app.id}`}>
</PageTitle> </PageTitle>
<AppActionButtons app={app} /> <AppActionButtons session={session} app={app} />
{children} {children}
</div> </div>
); );

View File

@@ -8,55 +8,54 @@ import buildService from "@/server/services/build.service";
import deploymentService from "@/server/services/deployment.service"; import deploymentService from "@/server/services/deployment.service";
import monitoringService from "@/server/services/monitoring.service"; import monitoringService from "@/server/services/monitoring.service";
import podService from "@/server/services/pod.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 { PodsResourceInfoModel } from "@/shared/model/pods-resource-info.model";
import appLogsService from "@/server/services/standalone-services/app-logs.service"; import appLogsService from "@/server/services/standalone-services/app-logs.service";
import { DownloadableAppLogsModel } from "@/shared/model/downloadable-app-logs.model"; import { DownloadableAppLogsModel } from "@/shared/model/downloadable-app-logs.model";
import { ServiceException } from "@/shared/model/service.exception.model"; import { ServiceException } from "@/shared/model/service.exception.model";
export const getDeploymentsAndBuildsForApp = async (appId: string) => export const getDeploymentsAndBuildsForApp = async (appId: string) =>
simpleAction(async () => { simpleAction(async () => {
await getAuthUserSession(); await isAuthorizedReadForApp(appId);
const app = await appService.getExtendedById(appId); const app = await appService.getExtendedById(appId);
return await deploymentService.getDeploymentHistory(app.projectId, appId); return await deploymentService.getDeploymentHistory(app.projectId, appId);
}) as Promise<ServerActionResult<unknown, DeploymentInfoModel[]>>; }) as Promise<ServerActionResult<unknown, DeploymentInfoModel[]>>;
export const deleteBuild = async (buildName: string) => export const deleteBuild = async (buildName: string) =>
simpleAction(async () => { simpleAction(async () => {
await getAuthUserSession(); await isAuthorizedWriteForApp(await buildService.getAppIdByBuildName(buildName));
await buildService.deleteBuild(buildName); await buildService.deleteBuild(buildName);
return new SuccessActionResult(undefined, 'Successfully stopped and deleted build.'); return new SuccessActionResult(undefined, 'Successfully stopped and deleted build.');
}) as Promise<ServerActionResult<unknown, void>>; }) as Promise<ServerActionResult<unknown, void>>;
export const getPodsForApp = async (appId: string) => export const getPodsForApp = async (appId: string) =>
simpleAction(async () => { simpleAction(async () => {
await getAuthUserSession(); await isAuthorizedReadForApp(appId);
const app = await appService.getExtendedById(appId); const app = await appService.getExtendedById(appId);
return await podService.getPodsForApp(app.projectId, appId); return await podService.getPodsForApp(app.projectId, appId);
}) as Promise<ServerActionResult<unknown, PodsInfoModel[]>>; }) as Promise<ServerActionResult<unknown, PodsInfoModel[]>>;
export const getRessourceDataApp = async (projectId: string, appId: string) => export const getRessourceDataApp = async (projectId: string, appId: string) =>
simpleAction(async () => { simpleAction(async () => {
await getAuthUserSession(); await isAuthorizedReadForApp(appId);
return await monitoringService.getMonitoringForApp(projectId, appId); return await monitoringService.getMonitoringForApp(projectId, appId);
}) as Promise<ServerActionResult<unknown, PodsResourceInfoModel>>; }) as Promise<ServerActionResult<unknown, PodsResourceInfoModel>>;
export const createNewWebhookUrl = async (appId: string) => export const createNewWebhookUrl = async (appId: string) =>
simpleAction(async () => { simpleAction(async () => {
await getAuthUserSession(); await isAuthorizedWriteForApp(appId);
await appService.regenerateWebhookId(appId); await appService.regenerateWebhookId(appId);
}); });
export const getDownloadableLogs = async (appId: string) => export const getDownloadableLogs = async (appId: string) =>
simpleAction(async () => { simpleAction(async () => {
await getAuthUserSession(); await isAuthorizedReadForApp(appId);
return new SuccessActionResult(await appLogsService.getAvailableLogsForApp(appId)); return new SuccessActionResult(await appLogsService.getAvailableLogsForApp(appId));
}) as Promise<ServerActionResult<unknown, DownloadableAppLogsModel[]>>; }) as Promise<ServerActionResult<unknown, DownloadableAppLogsModel[]>>;
export const exportLogsToFileForToday = async (appId: string) => export const exportLogsToFileForToday = async (appId: string) =>
simpleAction(async () => { simpleAction(async () => {
await getAuthUserSession(); await isAuthorizedReadForApp(appId);
const result = await appLogsService.writeAppLogsToDiskForApp(appId); const result = await appLogsService.writeAppLogsToDiskForApp(appId);
if (!result) { if (!result) {
throw new ServiceException('There are no logs available for today.'); 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 DeploymentStatusBadge from "./deployment-status-badge";
import { BuildLogsDialog } from "./build-logs-overlay"; import { BuildLogsDialog } from "./build-logs-overlay";
import ShortCommitHash from "@/components/custom/short-commit-hash"; import ShortCommitHash from "@/components/custom/short-commit-hash";
import { RolePermissionEnum } from "@/shared/model/role-extended.model.ts";
export default function BuildsTab({ export default function BuildsTab({
app app,
role
}: { }: {
app: AppExtendedModel; app: AppExtendedModel;
role: RolePermissionEnum;
}) { }) {
const { openConfirmDialog: openDialog } = useConfirmDialog(); const { openConfirmDialog: openDialog } = useConfirmDialog();
@@ -89,7 +92,7 @@ export default function BuildsTab({
<div className="flex gap-4"> <div className="flex gap-4">
<div className="flex-1"></div> <div className="flex-1"></div>
{item.deploymentId && <Button variant="secondary" onClick={() => setSelectedDeploymentForLogs(item)}>Show Logs</Button>} {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> </div>
</> </>
}} }}

View File

@@ -13,11 +13,14 @@ import { Download, Expand, Terminal } from "lucide-react";
import { TerminalDialog } from "./terminal-overlay"; import { TerminalDialog } from "./terminal-overlay";
import { LogsDownloadOverlay } from "./logs-download-overlay"; import { LogsDownloadOverlay } from "./logs-download-overlay";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { RolePermissionEnum } from "@/shared/model/role-extended.model.ts";
export default function Logs({ export default function Logs({
app app,
role
}: { }: {
app: AppExtendedModel; app: AppExtendedModel;
role: RolePermissionEnum;
}) { }) {
const [selectedPod, setSelectedPod] = useState<PodsInfoModel | undefined>(undefined); const [selectedPod, setSelectedPod] = useState<PodsInfoModel | undefined>(undefined);
const [appPods, setAppPods] = useState<PodsInfoModel[] | undefined>(undefined); const [appPods, setAppPods] = useState<PodsInfoModel[] | undefined>(undefined);
@@ -76,7 +79,7 @@ export default function Logs({
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div> {role === RolePermissionEnum.READWRITE && <div>
<TerminalDialog terminalInfo={{ <TerminalDialog terminalInfo={{
podName: selectedPod.podName, podName: selectedPod.podName,
containerName: selectedPod.containerName, containerName: selectedPod.containerName,
@@ -86,7 +89,7 @@ export default function Logs({
<Terminal /> Terminal <Terminal /> Terminal
</Button> </Button>
</TerminalDialog> </TerminalDialog>
</div> </div>}
<div> <div>
<TooltipProvider> <TooltipProvider>
<Tooltip delayDuration={300}> <Tooltip delayDuration={300}>

View File

@@ -7,11 +7,14 @@ import { useConfirmDialog } from "@/frontend/states/zustand.states";
import { Toast } from "@/frontend/utils/toast.utils"; import { Toast } from "@/frontend/utils/toast.utils";
import { ClipboardCopy } from "lucide-react"; import { ClipboardCopy } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { RolePermissionEnum } from "@/shared/model/role-extended.model.ts";
export default function WebhookDeploymentInfo({ export default function WebhookDeploymentInfo({
app app,
role
}: { }: {
app: AppExtendedModel; app: AppExtendedModel;
role: RolePermissionEnum;
}) { }) {
const { openConfirmDialog } = useConfirmDialog(); const { openConfirmDialog } = useConfirmDialog();
const [webhookUrl, setWebhookUrl] = useState<string | undefined>(undefined); 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}> {webhookUrl && <Button className="flex-1 truncate" variant="secondary" onClick={copyWebhookUrl}>
<span className="truncate">{webhookUrl}</span> <ClipboardCopy /> <span className="truncate">{webhookUrl}</span> <ClipboardCopy />
</Button>} </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> </div>
</CardContent> </CardContent>
</Card> </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 appService from "@/server/services/app.service";
import AppTabs from "./app-tabs"; import AppTabs from "./app-tabs";
import AppBreadcrumbs from "./app-breadcrumbs"; import AppBreadcrumbs from "./app-breadcrumbs";
import s3TargetService from "@/server/services/s3-target.service"; import s3TargetService from "@/server/services/s3-target.service";
import volumeBackupService from "@/server/services/volume-backup.service"; import volumeBackupService from "@/server/services/volume-backup.service";
import { UserGroupUtils } from "@/shared/utils/role.utils";
export default async function AppPage({ export default async function AppPage({
searchParams, searchParams,
@@ -12,11 +13,12 @@ export default async function AppPage({
searchParams?: { [key: string]: string | undefined }; searchParams?: { [key: string]: string | undefined };
params: { appId: string } params: { appId: string }
}) { }) {
await getAuthUserSession();
const appId = params?.appId; const appId = params?.appId;
if (!appId) { if (!appId) {
return <p>Could not find app with id {appId}</p> 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([ const [app, s3Targets, volumeBackups] = await Promise.all([
appService.getExtendedById(appId), appService.getExtendedById(appId),
s3TargetService.getAll(), s3TargetService.getAll(),
@@ -25,6 +27,7 @@ export default async function AppPage({
return (<> return (<>
<AppTabs <AppTabs
role={role!}
volumeBackups={volumeBackups} volumeBackups={volumeBackups}
s3Targets={s3Targets} s3Targets={s3Targets}
app={app} app={app}

View File

@@ -3,7 +3,7 @@
import { appVolumeEditZodModel } from "@/shared/model/volume-edit.model"; import { appVolumeEditZodModel } from "@/shared/model/volume-edit.model";
import { ServerActionResult, SuccessActionResult } from "@/shared/model/server-action-error-return.model"; import { ServerActionResult, SuccessActionResult } from "@/shared/model/server-action-error-return.model";
import appService from "@/server/services/app.service"; 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 { z } from "zod";
import { ServiceException } from "@/shared/model/service.exception.model"; import { ServiceException } from "@/shared/model/service.exception.model";
import pvcService from "@/server/services/pvc.service"; 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 restoreService from "@/server/services/restore.service";
import fileBrowserService from "@/server/services/file-browser-service"; import fileBrowserService from "@/server/services/file-browser-service";
import monitoringService from "@/server/services/monitoring.service"; import monitoringService from "@/server/services/monitoring.service";
import dataAccess from "@/server/adapter/db.client";
const actionAppVolumeEditZodModel = appVolumeEditZodModel.merge(z.object({ const actionAppVolumeEditZodModel = appVolumeEditZodModel.merge(z.object({
appId: z.string(), appId: z.string(),
@@ -23,7 +24,7 @@ const actionAppVolumeEditZodModel = appVolumeEditZodModel.merge(z.object({
export const restoreVolumeFromZip = async (prevState: any, inputData: FormData, volumeId: string) => export const restoreVolumeFromZip = async (prevState: any, inputData: FormData, volumeId: string) =>
simpleAction(async () => { simpleAction(async () => {
await getAuthUserSession(); await validateVolumeWriteAuthorization(volumeId);
const validatedData = volumeUploadZodModel.parse({ const validatedData = volumeUploadZodModel.parse({
volumeId, volumeId,
file: '' file: ''
@@ -39,7 +40,7 @@ export const restoreVolumeFromZip = async (prevState: any, inputData: FormData,
export const saveVolume = async (prevState: any, inputData: z.infer<typeof actionAppVolumeEditZodModel>) => export const saveVolume = async (prevState: any, inputData: z.infer<typeof actionAppVolumeEditZodModel>) =>
saveFormAction(inputData, actionAppVolumeEditZodModel, async (validatedData) => { saveFormAction(inputData, actionAppVolumeEditZodModel, async (validatedData) => {
await getAuthUserSession(); await isAuthorizedWriteForApp(validatedData.appId);
const existingApp = await appService.getExtendedById(validatedData.appId); const existingApp = await appService.getExtendedById(validatedData.appId);
const existingVolume = validatedData.id ? await appService.getVolumeById(validatedData.id) : undefined; const existingVolume = validatedData.id ? await appService.getVolumeById(validatedData.id) : undefined;
if (existingVolume && existingVolume.size > validatedData.size) { 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) => export const deleteVolume = async (volumeId: string) =>
simpleAction(async () => { simpleAction(async () => {
await getAuthUserSession(); await validateVolumeWriteAuthorization(volumeId);
await appService.deleteVolumeById(volumeId); await appService.deleteVolumeById(volumeId);
return new SuccessActionResult(undefined, 'Successfully deleted volume'); return new SuccessActionResult(undefined, 'Successfully deleted volume');
}); });
export const getPvcUsage = async (appId: string, projectId: string) => export const getPvcUsage = async (appId: string, projectId: string) =>
simpleAction(async () => { simpleAction(async () => {
await getAuthUserSession(); await isAuthorizedReadForApp(appId);
return monitoringService.getPvcUsageFromApp(appId, projectId); return monitoringService.getPvcUsageFromApp(appId, projectId);
}) as Promise<ServerActionResult<any, { pvcName: string, usedBytes: number }[]>>; }) as Promise<ServerActionResult<any, { pvcName: string, usedBytes: number }[]>>;
export const downloadPvcData = async (volumeId: string) => export const downloadPvcData = async (volumeId: string) =>
simpleAction(async () => { simpleAction(async () => {
await getAuthUserSession(); await validateVolumeReadAuthorization(volumeId);
const fileNameOfDownloadedFile = await pvcService.downloadPvcData(volumeId); const fileNameOfDownloadedFile = await pvcService.downloadPvcData(volumeId);
return new SuccessActionResult(fileNameOfDownloadedFile, 'Successfully zipped volume data'); // returns the download path on the server return new SuccessActionResult(fileNameOfDownloadedFile, 'Successfully zipped volume data'); // returns the download path on the server
}) as Promise<ServerActionResult<any, string>>; }) 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>) => export const saveFileMount = async (prevState: any, inputData: z.infer<typeof actionAppFileMountEditZodModel>) =>
saveFormAction(inputData, actionAppFileMountEditZodModel, async (validatedData) => { saveFormAction(inputData, actionAppFileMountEditZodModel, async (validatedData) => {
await getAuthUserSession(); await isAuthorizedWriteForApp(validatedData.appId);
await appService.saveFileMount({ await appService.saveFileMount({
...validatedData, ...validatedData,
id: validatedData.id ?? undefined, 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) => export const deleteFileMount = async (fileMountId: string) =>
simpleAction(async () => { simpleAction(async () => {
await getAuthUserSession(); await validateFileMountWriteAuthorization(fileMountId);
await appService.deleteFileMountById(fileMountId); await appService.deleteFileMountById(fileMountId);
return new SuccessActionResult(undefined, 'Successfully deleted volume'); return new SuccessActionResult(undefined, 'Successfully deleted volume');
}); });
export const saveBackupVolume = async (prevState: any, inputData: VolumeBackupEditModel) => export const saveBackupVolume = async (prevState: any, inputData: VolumeBackupEditModel) =>
saveFormAction(inputData, volumeBackupEditZodModel, async (validatedData) => { saveFormAction(inputData, volumeBackupEditZodModel, async (validatedData) => {
await getAuthUserSession(); await validateVolumeWriteAuthorization(validatedData.volumeId);
if (validatedData.retention < 1) { if (validatedData.retention < 1) {
throw new ServiceException('Retention must be at least 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) => export const deleteBackupVolume = async (backupVolumeId: string) =>
simpleAction(async () => { simpleAction(async () => {
await getAuthUserSession(); await validateBackupVolumeWriteAuthorization(backupVolumeId);
await volumeBackupService.deleteById(backupVolumeId); await volumeBackupService.deleteById(backupVolumeId);
await backupService.registerAllBackups(); await backupService.registerAllBackups();
return new SuccessActionResult(undefined, 'Successfully deleted backup schedule'); return new SuccessActionResult(undefined, 'Successfully deleted backup schedule');
@@ -120,17 +121,69 @@ export const deleteBackupVolume = async (backupVolumeId: string) =>
export const runBackupVolumeSchedule = async (backupVolumeId: string) => export const runBackupVolumeSchedule = async (backupVolumeId: string) =>
simpleAction(async () => { simpleAction(async () => {
await getAuthUserSession(); await validateBackupVolumeWriteAuthorization(backupVolumeId);
await backupService.runBackupForVolume(backupVolumeId); await backupService.runBackupForVolume(backupVolumeId);
return new SuccessActionResult(undefined, 'Backup created and uploaded successfully'); return new SuccessActionResult(undefined, 'Backup created and uploaded successfully');
}); });
export const openFileBrowserForVolume = async (volumeId: string) => export const openFileBrowserForVolume = async (volumeId: string) =>
simpleAction(async () => { simpleAction(async () => {
await getAuthUserSession(); await validateVolumeWriteAuthorization(volumeId);
const fileBrowserDomain = await fileBrowserService.deployFileBrowserForVolume(volumeId); const fileBrowserDomain = await fileBrowserService.deployFileBrowserForVolume(volumeId);
return new SuccessActionResult(fileBrowserDomain, 'File browser started successfully'); return new SuccessActionResult(fileBrowserDomain, 'File browser started successfully');
}) as Promise<ServerActionResult<any, { }) as Promise<ServerActionResult<any, {
url: string; url: string;
password: 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 }); type AppVolumeWithCapacity = (AppVolume & { capacity?: string });
export default function FileMount({ app }: { export default function FileMount({ app, readonly }: {
app: AppExtendedModel app: AppExtendedModel;
readonly: boolean;
}) { }) {
const { openConfirmDialog: openDialog } = useConfirmDialog(); const { openConfirmDialog: openDialog } = useConfirmDialog();
@@ -50,24 +51,25 @@ export default function FileMount({ app }: {
{app.appFileMounts.map(fileMount => ( {app.appFileMounts.map(fileMount => (
<TableRow key={fileMount.containerMountPath}> <TableRow key={fileMount.containerMountPath}>
<TableCell className="font-medium">{fileMount.containerMountPath}</TableCell> <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}> <FileMountEditDialog app={app} fileMount={fileMount}>
<Button variant="ghost"><EditIcon /></Button> <Button variant="ghost"><EditIcon /></Button>
</FileMountEditDialog> </FileMountEditDialog>
<Button variant="ghost" onClick={() => asyncDeleteFileMount(fileMount.id)}> <Button variant="ghost" onClick={() => asyncDeleteFileMount(fileMount.id)}>
<TrashIcon /> <TrashIcon />
</Button> </Button>
</TableCell> </TableCell>}
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
</Table> </Table>
</CardContent> </CardContent>
<CardFooter> {!readonly && <CardFooter>
<FileMountEditDialog app={app}> <FileMountEditDialog app={app}>
<Button>Add File Mount</Button> <Button>Add File Mount</Button>
</FileMountEditDialog> </FileMountEditDialog>
</CardFooter> </CardFooter>
}
</Card > </Card >
</>; </>;
} }

View File

@@ -25,8 +25,9 @@ import { Progress } from "@/components/ui/progress";
type AppVolumeWithCapacity = (AppVolume & { usedBytes?: number; capacityBytes?: number; usedPercentage?: number }); type AppVolumeWithCapacity = (AppVolume & { usedBytes?: number; capacityBytes?: number; usedPercentage?: number });
export default function StorageList({ app }: { export default function StorageList({ app, readonly }: {
app: AppExtendedModel app: AppExtendedModel;
readonly: boolean;
}) { }) {
const [volumesWithStorage, setVolumesWithStorage] = React.useState<AppVolumeWithCapacity[]>(app.appVolumes); const [volumesWithStorage, setVolumesWithStorage] = React.useState<AppVolumeWithCapacity[]>(app.appVolumes);
@@ -181,7 +182,7 @@ export default function StorageList({ app }: {
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
<TooltipProvider> {!readonly && <TooltipProvider>
<Tooltip delayDuration={200}> <Tooltip delayDuration={200}>
<TooltipTrigger> <TooltipTrigger>
<Button variant="ghost" onClick={() => openFileBrowserForVolumeAsync(volume.id)} disabled={isLoading}> <Button variant="ghost" onClick={() => openFileBrowserForVolumeAsync(volume.id)} disabled={isLoading}>
@@ -192,7 +193,7 @@ export default function StorageList({ app }: {
<p>View content of Volume</p> <p>View content of Volume</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>}
{/*<StorageRestoreDialog app={app} volume={volume}> {/*<StorageRestoreDialog app={app} volume={volume}>
<TooltipProvider> <TooltipProvider>
<Tooltip delayDuration={200}> <Tooltip delayDuration={200}>
@@ -207,41 +208,43 @@ export default function StorageList({ app }: {
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
</StorageRestoreDialog>*/} </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> <TooltipProvider>
<Tooltip delayDuration={200}> <Tooltip delayDuration={200}>
<TooltipTrigger> <TooltipTrigger>
<Button variant="ghost" disabled={isLoading}><EditIcon /></Button> <Button variant="ghost" onClick={() => asyncDeleteVolume(volume.id)} disabled={isLoading}>
<TrashIcon />
</Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
<p>Edit volume settings</p> <p>Delete volume</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </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> </TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
</Table> </Table>
</CardContent> </CardContent>
<CardFooter> {!readonly && <CardFooter>
<DialogEditDialog app={app}> <DialogEditDialog app={app}>
<Button>Add Volume</Button> <Button>Add Volume</Button>
</DialogEditDialog> </DialogEditDialog>
</CardFooter> </CardFooter>}
</Card > </Card >
</>; </>;
} }

View File

@@ -17,11 +17,13 @@ import { VolumeBackupExtendedModel } from "@/shared/model/volume-backup-extended
export default function VolumeBackupList({ export default function VolumeBackupList({
app, app,
volumeBackups, volumeBackups,
s3Targets s3Targets,
readonly
}: { }: {
app: AppExtendedModel, app: AppExtendedModel,
s3Targets: S3Target[], s3Targets: S3Target[],
volumeBackups: VolumeBackupExtendedModel[] volumeBackups: VolumeBackupExtendedModel[];
readonly: boolean;
}) { }) {
const { openConfirmDialog: openDialog } = useConfirmDialog(); const { openConfirmDialog: openDialog } = useConfirmDialog();
@@ -79,7 +81,7 @@ export default function VolumeBackupList({
<TableCell className="font-medium">{volumeBackup.retention}</TableCell> <TableCell className="font-medium">{volumeBackup.retention}</TableCell>
<TableCell className="font-medium">{volumeBackup.target.name}</TableCell> <TableCell className="font-medium">{volumeBackup.target.name}</TableCell>
<TableCell className="font-medium">{formatDateTime(volumeBackup.createdAt)}</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)}> <Button disabled={isLoading} variant="ghost" onClick={() => asyncRunBackupVolumeSchedule(volumeBackup.id)}>
<Play /> <Play />
</Button> </Button>
@@ -90,17 +92,17 @@ export default function VolumeBackupList({
<Button disabled={isLoading} variant="ghost" onClick={() => asyncDeleteBackupVolume(volumeBackup.id)}> <Button disabled={isLoading} variant="ghost" onClick={() => asyncDeleteBackupVolume(volumeBackup.id)}>
<TrashIcon /> <TrashIcon />
</Button> </Button>
</TableCell> </TableCell>}
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
</Table> </Table>
</CardContent> </CardContent>
<CardFooter> {!readonly && <CardFooter>
<VolumeBackupEditDialog s3Targets={s3Targets} volumes={app.appVolumes}> <VolumeBackupEditDialog s3Targets={s3Targets} volumes={app.appVolumes}>
<Button>Add Backup Schedule</Button> <Button>Add Backup Schedule</Button>
</VolumeBackupEditDialog> </VolumeBackupEditDialog>
</CardFooter> </CardFooter>}
</Card > </Card >
</>; </>;
} }

View File

@@ -2,8 +2,10 @@
import { SuccessActionResult } from "@/shared/model/server-action-error-return.model"; import { SuccessActionResult } from "@/shared/model/server-action-error-return.model";
import projectService from "@/server/services/project.service"; 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 { z } from "zod";
import { UserGroupUtils } from "@/shared/utils/role.utils";
import { ServiceException } from "@/shared/model/service.exception.model";
const createProjectSchema = z.object({ const createProjectSchema = z.object({
projectName: z.string().min(1), projectName: z.string().min(1),
@@ -12,7 +14,7 @@ const createProjectSchema = z.object({
export const createProject = async (projectName: string, projectId?: string) => export const createProject = async (projectName: string, projectId?: string) =>
saveFormAction({ projectName, projectId }, createProjectSchema, async (validatedData) => { saveFormAction({ projectName, projectId }, createProjectSchema, async (validatedData) => {
await getAuthUserSession(); const session = await getAdminUserSession();
await projectService.save({ await projectService.save({
id: validatedData.projectId ?? undefined, id: validatedData.projectId ?? undefined,
name: validatedData.projectName name: validatedData.projectName
@@ -22,7 +24,7 @@ export const createProject = async (projectName: string, projectId?: string) =>
export const deleteProject = async (projectId: string) => export const deleteProject = async (projectId: string) =>
simpleAction(async () => { simpleAction(async () => {
await getAuthUserSession(); await getAdminUserSession();
await projectService.deleteById(projectId); await projectService.deleteById(projectId);
return new SuccessActionResult(undefined, "Project deleted successfully."); return new SuccessActionResult(undefined, "Project deleted successfully.");
}); });

View File

@@ -18,21 +18,24 @@ import {
import { useBreadcrumbs } from "@/frontend/states/zustand.states"; import { useBreadcrumbs } from "@/frontend/states/zustand.states";
import ProjectsBreadcrumbs from "./projects-breadcrumbs"; import ProjectsBreadcrumbs from "./projects-breadcrumbs";
import { Plus } from "lucide-react"; import { Plus } from "lucide-react";
import { UserGroupUtils } from "@/shared/utils/role.utils";
export default async function ProjectPage() { export default async function ProjectPage() {
await getAuthUserSession(); const session = await getAuthUserSession();
const data = await projectService.getAllProjects(); const data = await projectService.getAllProjects();
const relevantProjectsForUser = data.filter((project) =>
UserGroupUtils.sessionHasReadAccessToProject(session, project.id));
return ( return (
<div className="flex-1 space-y-4 pt-6"> <div className="flex-1 space-y-4 pt-6">
<div className="flex gap-4"> <div className="flex gap-4">
<h2 className="text-3xl font-bold tracking-tight flex-1">Projects</h2> <h2 className="text-3xl font-bold tracking-tight flex-1">Projects</h2>
<EditProjectDialog> {UserGroupUtils.isAdmin(session) && <EditProjectDialog>
<Button><Plus /> Create Project</Button> <Button><Plus /> Create Project</Button>
</EditProjectDialog> </EditProjectDialog>}
</div> </div>
<ProjectsTable data={data} /> <ProjectsTable session={session} data={relevantProjectsForUser} />
<ProjectsBreadcrumbs /> <ProjectsBreadcrumbs />
</div> </div>
) )

View File

@@ -10,13 +10,13 @@ import { Edit2, Eye, MoreHorizontal, Trash } from "lucide-react";
import { Toast } from "@/frontend/utils/toast.utils"; import { Toast } from "@/frontend/utils/toast.utils";
import { Project } from "@prisma/client"; import { Project } from "@prisma/client";
import { deleteProject } from "./actions"; import { deleteProject } from "./actions";
import { useBreadcrumbs, useConfirmDialog } from "@/frontend/states/zustand.states"; import { useConfirmDialog } from "@/frontend/states/zustand.states";
import { useEffect } from "react";
import { EditProjectDialog } from "./edit-project-dialog"; 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, session }: { data: Project[]; session: UserSession; }) {
export default function ProjectsTable({ data }: { data: Project[] }) {
const { openConfirmDialog: openDialog } = useConfirmDialog(); const { openConfirmDialog: openDialog } = useConfirmDialog();
@@ -59,14 +59,16 @@ export default function ProjectsTable({ data }: { data: Project[] }) {
</DropdownMenuItem> </DropdownMenuItem>
</Link> </Link>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<EditProjectDialog existingItem={item}> {UserGroupUtils.isAdmin(session) && <>
<DropdownMenuItem> <EditProjectDialog existingItem={item}>
<Edit2 /> <span>Edit Project Name</span> <DropdownMenuItem>
<Edit2 /> <span>Edit Project Name</span>
</DropdownMenuItem>
</EditProjectDialog>
<DropdownMenuItem className="text-red-500" onClick={() => asyncDeleteProject(item.id)}>
<Trash /> <span >Delete Project</span>
</DropdownMenuItem> </DropdownMenuItem>
</EditProjectDialog> </>}
<DropdownMenuItem className="text-red-500" onClick={() => asyncDeleteProject(item.id)}>
<Trash /> <span >Delete Project</span>
</DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div> </div>

View File

@@ -1,12 +1,12 @@
'use server' '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 { SuccessActionResult } from "@/shared/model/server-action-error-return.model";
import clusterService from "@/server/services/node.service"; import clusterService from "@/server/services/node.service";
export const setNodeStatus = async (nodeName: string, schedulable: boolean) => export const setNodeStatus = async (nodeName: string, schedulable: boolean) =>
simpleAction(async () => { simpleAction(async () => {
await getAuthUserSession(); await getAdminUserSession();
await clusterService.setNodeStatus(nodeName, schedulable); await clusterService.setNodeStatus(nodeName, schedulable);
return new SuccessActionResult(undefined, 'Successfully updated node status.'); return new SuccessActionResult(undefined, 'Successfully updated node status.');
}); });

View File

@@ -1,6 +1,6 @@
'use server' '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 PageTitle from "@/components/custom/page-title";
import clusterService from "@/server/services/node.service"; import clusterService from "@/server/services/node.service";
import NodeInfo from "./nodeInfo"; import NodeInfo from "./nodeInfo";
@@ -11,7 +11,7 @@ import BreadcrumbSetter from "@/components/breadcrumbs-setter";
export default async function ClusterInfoPage() { export default async function ClusterInfoPage() {
const session = await getAuthUserSession(); const session = await getAdminUserSession();
const nodeInfo = await clusterService.getNodeInfo(); const nodeInfo = await clusterService.getNodeInfo();
const clusterJoinToken = await paramService.getString(ParamService.K3S_JOIN_TOKEN); const clusterJoinToken = await paramService.getString(ParamService.K3S_JOIN_TOKEN);
return ( return (

View File

@@ -1,6 +1,6 @@
'use server' '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 PageTitle from "@/components/custom/page-title";
import paramService, { ParamService } from "@/server/services/param.service"; import paramService, { ParamService } from "@/server/services/param.service";
import podService from "@/server/services/pod.service"; import podService from "@/server/services/pod.service";
@@ -13,7 +13,7 @@ import quickStackService from "@/server/services/qs.service";
export default async function MaintenancePage() { export default async function MaintenancePage() {
await getAuthUserSession(); await getAdminUserSession();
const useCanaryChannel = await paramService.getBoolean(ParamService.USE_CANARY_CHANNEL, false); const useCanaryChannel = await paramService.getBoolean(ParamService.USE_CANARY_CHANNEL, false);
const qsPodInfos = await podService.getPodsForApp(Constants.QS_NAMESPACE, Constants.QS_APP_NAME); const qsPodInfos = await podService.getPodsForApp(Constants.QS_NAMESPACE, Constants.QS_APP_NAME);
const qsPodInfo = qsPodInfos.find(p => !!p); const qsPodInfo = qsPodInfos.find(p => !!p);

View File

@@ -1,7 +1,7 @@
'use server' 'use server'
import { SuccessActionResult } from "@/shared/model/server-action-error-return.model"; 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 { S3TargetEditModel, s3TargetEditZodModel } from "@/shared/model/s3-target-edit.model";
import s3TargetService from "@/server/services/s3-target.service"; import s3TargetService from "@/server/services/s3-target.service";
import s3Service from "@/server/services/aws-s3.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) => export const saveS3Target = async (prevState: any, inputData: S3TargetEditModel) =>
saveFormAction(inputData, s3TargetEditZodModel, async (validatedData) => { saveFormAction(inputData, s3TargetEditZodModel, async (validatedData) => {
await getAuthUserSession(); await getAdminUserSession();
const url = new URL(validatedData.endpoint.includes('://') ? validatedData.endpoint : `https://${validatedData.endpoint}`); const url = new URL(validatedData.endpoint.includes('://') ? validatedData.endpoint : `https://${validatedData.endpoint}`);
validatedData.endpoint = url.hostname; validatedData.endpoint = url.hostname;
@@ -27,7 +27,7 @@ export const saveS3Target = async (prevState: any, inputData: S3TargetEditModel)
export const deleteS3Target = async (s3TargetId: string) => export const deleteS3Target = async (s3TargetId: string) =>
simpleAction(async () => { simpleAction(async () => {
await getAuthUserSession(); await getAdminUserSession();
await s3TargetService.deleteById(s3TargetId); await s3TargetService.deleteById(s3TargetId);
return new SuccessActionResult(undefined, 'Successfully deleted S3 Target'); return new SuccessActionResult(undefined, 'Successfully deleted S3 Target');
}); });

View File

@@ -1,6 +1,6 @@
'use server' '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 PageTitle from "@/components/custom/page-title";
import s3TargetService from "@/server/services/s3-target.service"; import s3TargetService from "@/server/services/s3-target.service";
import S3TargetsTable from "./s3-targets-table"; import S3TargetsTable from "./s3-targets-table";
@@ -10,7 +10,7 @@ import BreadcrumbSetter from "@/components/breadcrumbs-setter";
export default async function S3TargetsPage() { export default async function S3TargetsPage() {
await getAuthUserSession(); await getAdminUserSession();
const data = await s3TargetService.getAll(); const data = await s3TargetService.getAll();
return ( return (
<div className="flex-1 space-y-4 pt-6"> <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)], ["updatedAt", "Updated At", false, (item) => formatDateTime(item.updatedAt)],
]} ]}
data={targets} data={targets}
onItemClickLink={(item) => `/project/${item.id}`}
actionCol={(item) => actionCol={(item) =>
<> <>
<div className="flex"> <div className="flex">

View File

@@ -1,6 +1,6 @@
'use server' '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 paramService, { ParamService } from "@/server/services/param.service";
import { QsIngressSettingsModel, qsIngressSettingsZodModel } from "@/shared/model/qs-settings.model"; import { QsIngressSettingsModel, qsIngressSettingsZodModel } from "@/shared/model/qs-settings.model";
import { QsLetsEncryptSettingsModel, qsLetsEncryptSettingsZodModel } from "@/shared/model/qs-letsencrypt-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) => export const updateIngressSettings = async (prevState: any, inputData: QsIngressSettingsModel) =>
saveFormAction(inputData, qsIngressSettingsZodModel, async (validatedData) => { saveFormAction(inputData, qsIngressSettingsZodModel, async (validatedData) => {
await getAuthUserSession(); await getAdminUserSession();
const url = new URL(validatedData.serverUrl.includes('://') ? validatedData.serverUrl : `https://${validatedData.serverUrl}`); 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) => export const updatePublicIpv4Settings = async (prevState: any, inputData: QsPublicIpv4SettingsModel) =>
saveFormAction(inputData, qsPublicIpv4SettingsZodModel, async (validatedData) => { saveFormAction(inputData, qsPublicIpv4SettingsZodModel, async (validatedData) => {
await getAuthUserSession(); await getAdminUserSession();
await paramService.save({ await paramService.save({
name: ParamService.PUBLIC_IPV4_ADDRESS, name: ParamService.PUBLIC_IPV4_ADDRESS,
@@ -52,7 +52,7 @@ export const updatePublicIpv4Settings = async (prevState: any, inputData: QsPubl
export const updatePublicIpv4SettingsAutomatically = async () => export const updatePublicIpv4SettingsAutomatically = async () =>
simpleAction(async () => { simpleAction(async () => {
await getAuthUserSession(); await getAdminUserSession();
const publicIpv4 = await ipAddressFinderAdapter.getPublicIpOfServer(); const publicIpv4 = await ipAddressFinderAdapter.getPublicIpOfServer();
await paramService.save({ await paramService.save({
@@ -63,7 +63,7 @@ export const updatePublicIpv4SettingsAutomatically = async () =>
export const updateLetsEncryptSettings = async (prevState: any, inputData: QsLetsEncryptSettingsModel) => export const updateLetsEncryptSettings = async (prevState: any, inputData: QsLetsEncryptSettingsModel) =>
saveFormAction(inputData, qsLetsEncryptSettingsZodModel, async (validatedData) => { saveFormAction(inputData, qsLetsEncryptSettingsZodModel, async (validatedData) => {
await getAuthUserSession(); await getAdminUserSession();
await paramService.save({ await paramService.save({
name: ParamService.LETS_ENCRYPT_MAIL, 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 () => export const getConfiguredHostname: () => Promise<ServerActionResult<unknown, string | undefined>> = async () =>
simpleAction(async () => { simpleAction(async () => {
await getAuthUserSession(); await getAdminUserSession();
return await paramService.getString(ParamService.QS_SERVER_HOSTNAME); return await paramService.getString(ParamService.QS_SERVER_HOSTNAME);
}); });
@@ -83,21 +83,21 @@ export const getConfiguredHostname: () => Promise<ServerActionResult<unknown, st
export const cleanupOldTmpFiles = async () => export const cleanupOldTmpFiles = async () =>
simpleAction(async () => { simpleAction(async () => {
await getAuthUserSession(); await getAdminUserSession();
await maintenanceService.deleteAllTempFiles(); await maintenanceService.deleteAllTempFiles();
return new SuccessActionResult(undefined, 'Successfully cleaned up temp files.'); return new SuccessActionResult(undefined, 'Successfully cleaned up temp files.');
}); });
export const cleanupOldBuildJobs = async () => export const cleanupOldBuildJobs = async () =>
simpleAction(async () => { simpleAction(async () => {
await getAuthUserSession(); await getAdminUserSession();
await buildService.deleteAllFailedOrSuccededBuilds(); await buildService.deleteAllFailedOrSuccededBuilds();
return new SuccessActionResult(undefined, 'Successfully cleaned up old build jobs.'); return new SuccessActionResult(undefined, 'Successfully cleaned up old build jobs.');
}); });
export const updateQuickstack = async () => export const updateQuickstack = async () =>
simpleAction(async () => { simpleAction(async () => {
await getAuthUserSession(); await getAdminUserSession();
const useCaranyChannel = await paramService.getBoolean(ParamService.USE_CANARY_CHANNEL, false); const useCaranyChannel = await paramService.getBoolean(ParamService.USE_CANARY_CHANNEL, false);
await quickStackService.updateQuickStack(useCaranyChannel); await quickStackService.updateQuickStack(useCaranyChannel);
return new SuccessActionResult(undefined, 'QuickStack will be updated, refresh the page in a few seconds.'); 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 () => export const updateRegistry = async () =>
simpleAction(async () => { simpleAction(async () => {
await getAuthUserSession(); await getAdminUserSession();
const registryLocation = await paramService.getString(ParamService.REGISTRY_SOTRAGE_LOCATION, Constants.INTERNAL_REGISTRY_LOCATION); const registryLocation = await paramService.getString(ParamService.REGISTRY_SOTRAGE_LOCATION, Constants.INTERNAL_REGISTRY_LOCATION);
await registryService.deployRegistry(registryLocation!, true); await registryService.deployRegistry(registryLocation!, true);
return new SuccessActionResult(undefined, 'Registry will be updated, this might take a few seconds.'); 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 () => export const updateTraefikMeCertificates = async () =>
simpleAction(async () => { simpleAction(async () => {
await getAuthUserSession(); await getAdminUserSession();
await traefikMeDomainStandaloneService.updateTraefikMeCertificate(); await traefikMeDomainStandaloneService.updateTraefikMeCertificate();
return new SuccessActionResult(undefined, 'Certificates will be updated, this might take a few seconds.'); return new SuccessActionResult(undefined, 'Certificates will be updated, this might take a few seconds.');
}); });
export const deleteAllFailedAndSuccededPods = async () => export const deleteAllFailedAndSuccededPods = async () =>
simpleAction(async () => { simpleAction(async () => {
await getAuthUserSession(); await getAdminUserSession();
await standalonePodService.deleteAllFailedAndSuccededPods(); await standalonePodService.deleteAllFailedAndSuccededPods();
return new SuccessActionResult(undefined, 'Successfully deleted all failed and succeeded pods.'); return new SuccessActionResult(undefined, 'Successfully deleted all failed and succeeded pods.');
}); });
export const purgeRegistryImages = async () => export const purgeRegistryImages = async () =>
simpleAction(async () => { simpleAction(async () => {
await getAuthUserSession(); await getAdminUserSession();
const deletedSize = await registryService.purgeRegistryImages(); const deletedSize = await registryService.purgeRegistryImages();
return new SuccessActionResult(undefined, `Successfully purged ${KubeSizeConverter.convertBytesToReadableSize(deletedSize)} of images.`); return new SuccessActionResult(undefined, `Successfully purged ${KubeSizeConverter.convertBytesToReadableSize(deletedSize)} of images.`);
}); });
export const deleteOldAppLogs = async () => export const deleteOldAppLogs = async () =>
simpleAction(async () => { simpleAction(async () => {
await getAuthUserSession(); await getAdminUserSession();
await appLogsService.deleteOldAppLogs(); await appLogsService.deleteOldAppLogs();
return new SuccessActionResult(undefined, `Successfully deletes old app logs.`); return new SuccessActionResult(undefined, `Successfully deletes old app logs.`);
}); });
export const setCanaryChannel = async (useCanaryChannel: boolean) => export const setCanaryChannel = async (useCanaryChannel: boolean) =>
simpleAction(async () => { simpleAction(async () => {
await getAuthUserSession(); await getAdminUserSession();
await paramService.save({ await paramService.save({
name: ParamService.USE_CANARY_CHANNEL, name: ParamService.USE_CANARY_CHANNEL,
value: !!useCanaryChannel ? 'true' : 'false' value: !!useCanaryChannel ? 'true' : 'false'
@@ -151,7 +151,7 @@ export const setCanaryChannel = async (useCanaryChannel: boolean) =>
export const setRegistryStorageLocation = async (prevState: any, inputData: RegistryStorageLocationSettingsModel) => export const setRegistryStorageLocation = async (prevState: any, inputData: RegistryStorageLocationSettingsModel) =>
saveFormAction(inputData, registryStorageLocationSettingsZodModel, async (validatedData) => { saveFormAction(inputData, registryStorageLocationSettingsZodModel, async (validatedData) => {
await getAuthUserSession(); await getAdminUserSession();
await registryService.deployRegistry(validatedData.registryStorageLocation, true); await registryService.deployRegistry(validatedData.registryStorageLocation, true);

View File

@@ -1,6 +1,6 @@
'use server' '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 PageTitle from "@/components/custom/page-title";
import paramService, { ParamService } from "@/server/services/param.service"; import paramService, { ParamService } from "@/server/services/param.service";
import QuickStackIngressSettings from "./qs-ingress-settings"; import QuickStackIngressSettings from "./qs-ingress-settings";
@@ -13,7 +13,7 @@ import BreadcrumbSetter from "@/components/breadcrumbs-setter";
export default async function ProjectPage() { export default async function ProjectPage() {
const session = await getAuthUserSession(); const session = await getAdminUserSession();
const serverUrl = await paramService.getString(ParamService.QS_SERVER_HOSTNAME, ''); const serverUrl = await paramService.getString(ParamService.QS_SERVER_HOSTNAME, '');
const disableNodePortAccess = await paramService.getBoolean(ParamService.DISABLE_NODEPORT_ACCESS, false); const disableNodePortAccess = await paramService.getBoolean(ParamService.DISABLE_NODEPORT_ACCESS, false);
const letsEncryptMail = await paramService.getString(ParamService.LETS_ENCRYPT_MAIL, session.email); 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, SidebarMenuAction,
useSidebar useSidebar
} from "@/components/ui/sidebar" } 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 Link from "next/link"
import { EditProjectDialog } from "./projects/edit-project-dialog" import { EditProjectDialog } from "./projects/edit-project-dialog"
import { SidebarLogoutButton } from "./sidebar-logout-button" import { SidebarLogoutButton } from "./sidebar-logout-button"
@@ -31,6 +31,7 @@ import { usePathname } from "next/navigation"
import { useRouter } from "next/router" import { useRouter } from "next/router"
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import QuickStackLogo from "@/components/custom/quickstack-logo" import QuickStackLogo from "@/components/custom/quickstack-logo"
import { UserGroupUtils } from "@/shared/utils/role.utils"
const settingsMenu = [ const settingsMenu = [
@@ -39,25 +40,32 @@ const settingsMenu = [
url: "/settings/profile", url: "/settings/profile",
icon: User, icon: User,
}, },
{
title: "Users & Groups",
url: "/settings/users",
icon: User2,
adminOnly: true,
},
{ {
title: "S3 Targets", title: "S3 Targets",
url: "/settings/s3-targets", url: "/settings/s3-targets",
icon: Settings, icon: Settings,
adminOnly: true,
}, },
{ {
title: "QuickStack Settings", title: "QuickStack Settings",
url: "/settings/server", url: "/settings/server",
icon: Settings, adminOnly: true,
}, },
{ {
title: "Cluster", title: "Cluster",
url: "/settings/cluster", url: "/settings/cluster",
icon: Server, adminOnly: true,
}, },
{ {
title: "Maintenance", title: "Maintenance",
url: "/settings/maintenance", url: "/settings/maintenance",
icon: Settings, adminOnly: true,
}, },
] ]
@@ -113,7 +121,7 @@ export function SidebarCient({
<SidebarMenuButton size="lg" <SidebarMenuButton size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"> 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"> <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>
<div className="grid flex-1 text-left text-sm leading-tight my-4"> <div className="grid flex-1 text-left text-sm leading-tight my-4">
<span className="truncate font-semibold">QuickStack</span> <span className="truncate font-semibold">QuickStack</span>
@@ -157,11 +165,11 @@ export function SidebarCient({
<span>Projects</span> <span>Projects</span>
</Link> </Link>
</SidebarMenuButton> </SidebarMenuButton>
<EditProjectDialog> {UserGroupUtils.isAdmin(session) && <EditProjectDialog>
<SidebarMenuAction> <SidebarMenuAction>
<Plus /> <Plus />
</SidebarMenuAction> </SidebarMenuAction>
</EditProjectDialog> </EditProjectDialog>}
<SidebarMenu> <SidebarMenu>
{projects.map((item) => ( {projects.map((item) => (
<DropdownMenu key={item.id}> <DropdownMenu key={item.id}>
@@ -226,12 +234,12 @@ export function SidebarCient({
</SidebarGroup> </SidebarGroup>
<SidebarGroup> {UserGroupUtils.sessionHasAccessToBackups(session) && <SidebarGroup>
<SidebarGroupContent> <SidebarGroupContent>
<SidebarMenu> <SidebarMenu>
<SidebarMenuItem> <SidebarMenuItem>
<SidebarMenuButton asChild tooltip={{ <SidebarMenuButton asChild tooltip={{
children: 'Monitoring', children: 'Backups',
hidden: open, hidden: open,
}} }}
isActive={path.startsWith('/backups')}> isActive={path.startsWith('/backups')}>
@@ -243,7 +251,7 @@ export function SidebarCient({
</SidebarMenuItem> </SidebarMenuItem>
</SidebarMenu> </SidebarMenu>
</SidebarGroupContent> </SidebarGroupContent>
</SidebarGroup> </SidebarGroup>}
<SidebarGroup> <SidebarGroup>
@@ -260,16 +268,16 @@ export function SidebarCient({
</Link> </Link>
</SidebarMenuButton> </SidebarMenuButton>
<SidebarMenuSub> <SidebarMenuSub>
{settingsMenu.map((item) => ( {(UserGroupUtils.isAdmin(session) ? settingsMenu :
<SidebarMenuSubItem key={item.title}> settingsMenu.filter(x => !x.adminOnly)).map((item) => (
<SidebarMenuButton asChild> <SidebarMenuSubItem key={item.title}>
<Link href={item.url}> <SidebarMenuButton asChild>
<Link href={item.url}>
<span>{item.title}</span> <span>{item.title}</span>
</Link> </Link>
</SidebarMenuButton> </SidebarMenuButton>
</SidebarMenuSubItem> </SidebarMenuSubItem>
))} ))}
</SidebarMenuSub> </SidebarMenuSub>
</SidebarMenuItem> </SidebarMenuItem>
</SidebarMenu> </SidebarMenu>

View File

@@ -1,6 +1,7 @@
import projectService from "@/server/services/project.service" import projectService from "@/server/services/project.service"
import { getUserSession } from "@/server/utils/action-wrapper.utils" import { getUserSession } from "@/server/utils/action-wrapper.utils"
import { SidebarCient } from "./sidebar-client" import { SidebarCient } from "./sidebar-client"
import { UserGroupUtils } from "@/shared/utils/role.utils";
export async function AppSidebar() { export async function AppSidebar() {
@@ -12,5 +13,11 @@ export async function AppSidebar() {
const projects = await projectService.getAllProjects(); 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, VisibilityState,
getSortedRowModel, getSortedRowModel,
filterFns, filterFns,
FilterFnOption FilterFnOption,
PaginationState,
TableState
} from "@tanstack/react-table" } from "@tanstack/react-table"
import { import {
@@ -45,53 +47,80 @@ export function DefaultDataTable<TData, TValue>({
data, data,
globalFilterFn, globalFilterFn,
hideSearchBar = false, hideSearchBar = false,
onColumnVisabilityUpdate initialTableState,
onRowSelectionUpdate,
onTableStateChanged
}: DataTableProps<TData, TValue> & { }: DataTableProps<TData, TValue> & {
hideSearchBar?: boolean, hideSearchBar?: boolean;
onColumnVisabilityUpdate?: (visabilityConfig: [string, boolean][]) => void onRowSelectionUpdate?: (selectedItems: TData[]) => void;
globalFilterFn?: FilterFnOption<any> | undefined onTableStateChanged?: (state: Partial<TableState>) => void;
initialTableState?: Partial<TableState>;
globalFilterFn?: FilterFnOption<any> | undefined;
}) { }) {
const [sorting, setSorting] = React.useState<SortingState>([]); const [sorting, setSorting] = React.useState<SortingState>(initialTableState?.sorting ?? []);
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>( const [globalFilter, setGlobalFilter] = React.useState<any>(initialTableState?.globalFilter ?? []);
[] const [pagination, setPagination] = React.useState<PaginationState>(initialTableState?.pagination ?? {
); pageSize: 10,
const [globalFilter, setGlobalFilter] = React.useState<any>([]) pageIndex: 0
});
const initialVisabilityState = columns.filter(col => (col as any).isVisible === false).reduce((acc, col) => { const [rowSelection, setRowSelection] = React.useState<any>({});
acc[(col as any).accessorKey] = false; 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; return acc;
}, {} as VisibilityState); }, {} as VisibilityState);
const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>(initialVisabilityState);
const [columnVisibility, setColumnVisibility] =
React.useState<VisibilityState>(initialVisabilityState);
React.useEffect(() => { React.useEffect(() => {
if (onColumnVisabilityUpdate) { if (onRowSelectionUpdate) {
onColumnVisabilityUpdate(table.getAllColumns().filter(x => (x.columnDef as any).accessorKey).map(x => [(x.columnDef as any).accessorKey, x.getIsVisible()])); 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({ const table = useReactTable({
data, data,
columns, columns,
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(), getPaginationRowModel: getPaginationRowModel(),
onSortingChange: setSorting,
getSortedRowModel: getSortedRowModel(), getSortedRowModel: getSortedRowModel(),
onColumnFiltersChange: setColumnFilters,
getFilteredRowModel: getFilteredRowModel(), getFilteredRowModel: getFilteredRowModel(),
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
onColumnVisibilityChange: setColumnVisibility, onColumnVisibilityChange: setColumnVisibility,
onPaginationChange: setPagination,
onGlobalFilterChange: setGlobalFilter, onGlobalFilterChange: setGlobalFilter,
onRowSelectionChange: setRowSelection,
enableGlobalFilter: true, enableGlobalFilter: true,
globalFilterFn: globalFilterFn ?? filterFns.includesString, globalFilterFn: globalFilterFn ?? filterFns.includesString,
state: { state: {
sorting, sorting,
columnFilters, columnFilters,
columnVisibility, columnVisibility,
globalFilter globalFilter,
rowSelection,
pagination
}, },
}) });
React.useEffect(() => {
if (onTableStateChanged) {
onTableStateChanged({
sorting,
pagination,
globalFilter,
columnVisibility
})
}
}, [sorting, columnVisibility, globalFilter, pagination]);
return ( return (
<div> <div>
@@ -152,7 +181,7 @@ export function DefaultDataTable<TData, TValue>({
</Table> </Table>
</div> </div>
<div className="mt-4"> <div className="mt-4">
<DataTablePagination table={table} /> <DataTablePagination table={table}/>
</div> </div>
</div> </div>
) )

View File

@@ -1,13 +1,13 @@
"use client" "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 { DataTableColumnHeader } from "@/components/ui/column-header"
import { ReactNode, useEffect, useState } from "react" import { ReactNode, useEffect, useState } from "react"
import { DefaultDataTable } from "./default-data-table" import { DefaultDataTable } from "./default-data-table"
import { usePathname, useRouter } from "next/navigation" import { usePathname, useRouter } from "next/navigation"
import FullLoadingSpinner from "../ui/full-loading-spinnter" import FullLoadingSpinner from "../ui/full-loading-spinnter"
import { ReactNodeUtils } from "@/shared/utils/react-node.utils"
import { Checkbox } from "../ui/checkbox"
export function SimpleDataTable<TData>({ export function SimpleDataTable<TData>({
tableIdentifier, tableIdentifier,
@@ -17,48 +17,44 @@ export function SimpleDataTable<TData>({
onItemClick, onItemClick,
onItemClickLink, onItemClickLink,
hideSearchBar = false, hideSearchBar = false,
showSelectCheckbox = false,
onRowSelectionUpdate,
columnFilters,
}: { }: {
tableIdentifier?: string, tableIdentifier?: string,
columns: ([string, string, boolean, (item: TData) => ReactNode] | [string, string, boolean])[], columns: ([string, string, boolean, (item: TData) => ReactNode] | [string, string, boolean])[],
data: TData[], data: TData[],
hideSearchBar?: boolean, hideSearchBar?: boolean,
showSelectCheckbox?: boolean,
onItemClick?: (selectedItem: TData) => void, onItemClick?: (selectedItem: TData) => void,
onItemClickLink?: (selectedItem: TData) => string, 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 router = useRouter();
const pathName = usePathname(); 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 [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])[]) { const onTableStateChange = (newState: Partial<TableState>) => {
if (!columns) { const tableState = {
return; columnVisibility: newState.columnVisibility,
} sorting: newState.sorting,
const configFromLocalstorage = window.localStorage.getItem(`tableConfig-${tableIdentifier ?? pathName}`) || undefined; paginationPageSize: newState.pagination?.pageSize
let parsedConfig: [string, boolean][] = []; };
if (!!configFromLocalstorage) { window.localStorage.setItem(`table-${tableIdentifier ?? pathName}`, JSON.stringify(tableState));
parsedConfig = JSON.parse(configFromLocalstorage); window.sessionStorage.setItem(`table-${tableIdentifier ?? pathName}`, JSON.stringify({
} globalFilter: newState.globalFilter,
for (const col of columns) { paginationPageIndex: newState.pagination?.pageIndex
const [accessorKey, header, isVisible] = col; }));
const storedConfig = parsedConfig.find(([key]) => key === accessorKey);
if (storedConfig) {
col[2] = storedConfig[1];
}
}
} }
const updateVisabilityConfig = (visabilityConfig: [string, boolean][]) => {
window.localStorage.setItem(`tableConfig-${tableIdentifier ?? pathName}`, JSON.stringify(visabilityConfig));
}
useEffect(() => {
setUserVisabilityForColumns(columns);
setColumnsWithVisability(columns);
}, [columns]);
useEffect(() => { useEffect(() => {
const outData = data.map((item) => { const outData = data.map((item) => {
for (const [accessorKey, headerName, isVisible, customRowDefinition] of columns) { for (const [accessorKey, headerName, isVisible, customRowDefinition] of columns) {
@@ -70,9 +66,25 @@ export function SimpleDataTable<TData>({
return item; return item;
}); });
setColumnInputData(outData); 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]); }, [data, columns]);
if (!columnsWithVisability || !columnInputData) { if (!columnInputData || !initialTableState) {
return <FullLoadingSpinner />; return <FullLoadingSpinner />;
} }
@@ -86,10 +98,10 @@ export function SimpleDataTable<TData>({
const columnDefinitionForFilter = columns.find(col => col[0] === headerName); const columnDefinitionForFilter = columns.find(col => col[0] === headerName);
if (columnDefinitionForFilter && columnDefinitionForFilter[3]) { if (columnDefinitionForFilter && columnDefinitionForFilter[3]) {
const columnValue = columnDefinitionForFilter[3](row.original); const columnValue = columnDefinitionForFilter[3](row.original);
if (typeof columnValue === 'string') { const text = ReactNodeUtils.getTextFromReactElement(columnValue);
return columnValue.toLowerCase(); if (typeof text === 'string') {
return text.toLowerCase();
} }
return '';
} }
// use default column value for filtering // use default column value for filtering
return String(cell.getValue() ?? '').toLowerCase(); return String(cell.getValue() ?? '').toLowerCase();
@@ -97,23 +109,40 @@ export function SimpleDataTable<TData>({
return allCellValues.join(' ').includes(searchTerm.toLowerCase()); return allCellValues.join(' ').includes(searchTerm.toLowerCase());
}; };
const indexOfFirstVisibleColumn = columnsWithVisability.findIndex(([_, __, isVisible]) => isVisible); const indexOfFirstVisibleColumn = columns.findIndex(([_, __, isVisible]) => isVisible);
const dataColumns = columnsWithVisability.map(([accessorKey, header, isVisible, customRowDefinition], columnIndex) => { const dataColumns = columns.map(([accessorKey, header, isVisible, customRowDefinition], columnIndex) => {
const columnFiltersForThisColumn = columnFilters?.filter(filter => filter.accessorKey === accessorKey);
const dataCol = { const dataCol = {
accessorKey, accessorKey,
isVisible, isVisible,
headerName: header, headerName: header,
filterFn: (row, searchTerm) => { filterFn: (row, columnName, searchTerm) => {
const columnValue = ((customRowDefinition ? customRowDefinition(row.original) : (row.original as any)[accessorKey] as unknown as string) ?? ''); if (searchTerm === undefined || searchTerm === null || searchTerm === '') {
console.log(columnValue) return true;
if (typeof columnValue === 'string') { }
return columnValue.toLowerCase().includes(searchTerm.toLowerCase()); 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; return false;
}, },
header: ({ column }: { column: any }) => header && ( header: ({ column }: { column: any }) => header && (
<DataTableColumnHeader column={column} title={header} /> <DataTableColumnHeader disableSorting={!!customRowDefinition} column={column} title={header} filterOptions={columnFiltersForThisColumn} />
) )
} as ColumnDef<TData>; } as ColumnDef<TData>;
@@ -146,7 +175,31 @@ export function SimpleDataTable<TData>({
return dataCol; 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>[] = [ const finalCols: ColumnDef<TData>[] = [
...selectableColumns,
...dataColumns ...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 { cn } from "@/frontend/utils/utils"
import { Button } from "./button" import { Button } from "./button"
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator } from "./dropdown-menu" import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuLabel } from "./dropdown-menu"
import { import {
ArrowDownIcon, ArrowDownIcon,
ArrowUpIcon, ArrowUpIcon,
@@ -8,57 +8,128 @@ import {
EyeNoneIcon, EyeNoneIcon,
} from "@radix-ui/react-icons" } from "@radix-ui/react-icons"
import { Column } from "@tanstack/react-table" import { Column } from "@tanstack/react-table"
import { useState } from "react"
import { FilterIcon, FilterX, Trash2 } from "lucide-react"
interface DataTableColumnHeaderProps<TData, TValue> interface DataTableColumnHeaderProps<TData, TValue>
extends React.HTMLAttributes<HTMLDivElement> { extends React.HTMLAttributes<HTMLDivElement> {
column: Column<TData, TValue> column: Column<TData, TValue>
title: string title: string
filterOptions?: {
accessorKey: string,
filterLabel: string
}[],
disableSorting?: boolean
} }
export function DataTableColumnHeader<TData, TValue>({ export function DataTableColumnHeader<TData, TValue>({
column, column,
title, title,
className, className,
filterOptions = [],
disableSorting = false,
}: DataTableColumnHeaderProps<TData, TValue>) { }: DataTableColumnHeaderProps<TData, TValue>) {
if (!column.getCanSort()) { const [tempFilters, setTempFilters] = useState<string[]>([])
return <div className={cn(className)}>{title}</div>
}
return ( const handleFilterToggle = (option: string, column: Column<TData, TValue>) => {
<div className={cn("flex items-center space-x-2", className)}> let newFilters: string[] | undefined = tempFilters.includes(option)
<DropdownMenu> ? tempFilters.filter(x => x !== option)
<DropdownMenuTrigger asChild> : [...tempFilters, option]
<Button
variant="ghost" if (newFilters.length === 0) {
size="sm" newFilters = undefined;
className="-ml-3 h-8 data-[state=open]:bg-accent" }
> setTempFilters(newFilters ?? []);
<span>{title}</span> column.setFilterValue(newFilters);
{column.getIsSorted() === "desc" ? ( }
<ArrowDownIcon className="ml-2 h-4 w-4" />
) : column.getIsSorted() === "asc" ? ( const clearFilters = () => {
<ArrowUpIcon className="ml-2 h-4 w-4" /> setTempFilters([]);
) : ( column.setFilterValue(undefined);
<CaretSortIcon className="ml-2 h-4 w-4" /> }
)}
</Button> if (!column.getCanSort() && filterOptions.length === 0) {
</DropdownMenuTrigger> return <div className={cn(className)}>{title}</div>
<DropdownMenuContent align="start"> }
<DropdownMenuItem onClick={() => column.toggleSorting(false)}>
<ArrowUpIcon className="mr-2 h-3.5 w-3.5 text-muted-foreground/70" /> return (
Asc <div className={cn("flex items-center space-x-0.5", className)}>
</DropdownMenuItem> <DropdownMenu>
<DropdownMenuItem onClick={() => column.toggleSorting(true)}> <DropdownMenuTrigger asChild>
<ArrowDownIcon className="mr-2 h-3.5 w-3.5 text-muted-foreground/70" /> <Button
Desc variant="ghost"
</DropdownMenuItem> size="sm"
<DropdownMenuSeparator /> className="-ml-3 h-8 data-[state=open]:bg-accent"
<DropdownMenuItem onClick={() => column.toggleVisibility(false)}> >
<EyeNoneIcon className="mr-2 h-3.5 w-3.5 text-muted-foreground/70" /> <span>{title}</span>
Hide {!disableSorting && <>
</DropdownMenuItem> {
</DropdownMenuContent> column.getIsSorted() === "desc" ? (
</DropdownMenu> <ArrowDownIcon className="ml-2 h-4 w-4" />
</div> ) : 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 { Tags } from "../utils/cache-tag-generator.utils";
import { App, AppBasicAuth, AppDomain, AppFileMount, AppPort, AppVolume, Prisma } from "@prisma/client"; import { App, AppBasicAuth, AppDomain, AppFileMount, AppPort, AppVolume, Prisma } from "@prisma/client";
import { DefaultArgs } from "@prisma/client/runtime/library"; 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 { ServiceException } from "@/shared/model/service.exception.model";
import { KubeObjectNameUtils } from "../utils/kube-object-name.utils"; import { KubeObjectNameUtils } from "../utils/kube-object-name.utils";
import deploymentService from "./deployment.service"; import deploymentService from "./deployment.service";
@@ -63,6 +63,8 @@ class AppService {
revalidateTag(Tags.apps(existingApp.projectId)); revalidateTag(Tags.apps(existingApp.projectId));
revalidateTag(Tags.app(existingApp.id)); revalidateTag(Tags.app(existingApp.id));
revalidateTag(Tags.projects()); revalidateTag(Tags.projects());
revalidateTag(Tags.roles());
revalidateTag(Tags.users());
} }
} }
@@ -157,6 +159,8 @@ class AppService {
revalidateTag(Tags.apps(item.projectId as string)); revalidateTag(Tags.apps(item.projectId as string));
revalidateTag(Tags.app(item.id as string)); revalidateTag(Tags.app(item.id as string));
revalidateTag(Tags.projects()); revalidateTag(Tags.projects());
revalidateTag(Tags.roles());
revalidateTag(Tags.users());
} }
return savedItem; return savedItem;
} }
@@ -208,6 +212,14 @@ class AppService {
return savedItem; return savedItem;
} }
async getDomainById(id: string) {
return await dataAccess.client.appDomain.findFirstOrThrow({
where: {
id
}
});
}
async deleteDomainById(id: string) { async deleteDomainById(id: string) {
const existingDomain = await dataAccess.client.appDomain.findFirst({ const existingDomain = await dataAccess.client.appDomain.findFirst({
where: { where: {
@@ -399,6 +411,14 @@ class AppService {
return savedItem; return savedItem;
} }
async getPortById(portId: string) {
return await dataAccess.client.appPort.findFirstOrThrow({
where: {
id: portId
}
});
}
async deletePortById(id: string) { async deletePortById(id: string) {
const existingPort = await dataAccess.client.appPort.findFirst({ const existingPort = await dataAccess.client.appPort.findFirst({
where: { where: {
@@ -468,6 +488,42 @@ class AppService {
revalidateTag(Tags.apps(existingItem.app.projectId)); 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(); 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) { async deleteBuild(buildName: string) {
await k3s.batch.deleteNamespacedJob(buildName, BUILD_NAMESPACE); await k3s.batch.deleteNamespacedJob(buildName, BUILD_NAMESPACE);
console.log(`Deleted build job ${buildName}`); console.log(`Deleted build job ${buildName}`);

View File

@@ -7,6 +7,7 @@ import deploymentService from "./deployment.service";
import namespaceService from "./namespace.service"; import namespaceService from "./namespace.service";
import buildService from "./build.service"; import buildService from "./build.service";
import traefikMeDomainStandaloneService from "./standalone-services/traefik-me-domain-standalone.service"; import traefikMeDomainStandaloneService from "./standalone-services/traefik-me-domain-standalone.service";
import { ProjectExtendedModel } from "@/shared/model/project-extended.model";
class ProjectService { class ProjectService {
@@ -25,10 +26,12 @@ class ProjectService {
}); });
} finally { } finally {
revalidateTag(Tags.projects()); revalidateTag(Tags.projects());
revalidateTag(Tags.roles());
revalidateTag(Tags.users());
} }
} }
async getAllProjects() { async getAllProjects(): Promise<ProjectExtendedModel[]> {
return await unstable_cache(() => dataAccess.client.project.findMany({ return await unstable_cache(() => dataAccess.client.project.findMany({
include: { include: {
apps: true apps: true

View File

@@ -2,18 +2,24 @@ import dataAccess from "../../adapter/db.client";
import bcrypt from "bcrypt"; import bcrypt from "bcrypt";
import { randomBytes } from "crypto"; import { randomBytes } from "crypto";
import quickStackService from "../qs.service"; import quickStackService from "../qs.service";
import { adminRoleName } from "../../../shared/model/role-extended.model.ts";
class PasswordChangeService { class PasswordChangeService {
async changeAdminPasswordAndPrintNewPassword() { async changeAdminPasswordAndPrintNewPassword() {
const firstCreatedUser = await dataAccess.client.user.findFirst({ const firstCreatedUser = await dataAccess.client.user.findFirst({
where: {
userGroup: {
name: adminRoleName
}
},
orderBy: { orderBy: {
createdAt: 'asc' createdAt: 'asc'
} }
}); });
if (!firstCreatedUser) { 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; return;
} }
@@ -36,7 +42,7 @@ class PasswordChangeService {
console.log('******* Password change *******'); console.log('******* Password change *******');
console.log('*******************************'); console.log('*******************************');
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('*******************************'); 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 dataAccess from "../adapter/db.client";
import { revalidateTag, unstable_cache } from "next/cache"; import { revalidateTag, unstable_cache } from "next/cache";
import { Tags } from "../utils/cache-tag-generator.utils"; 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 { ServiceException } from "@/shared/model/service.exception.model";
import QRCode from "qrcode"; import QRCode from "qrcode";
import * as OTPAuth from "otpauth"; import * as OTPAuth from "otpauth";
import { UserExtended } from "@/shared/model/user-extended.model";
const saltRounds = 10; 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) { async maptoDtoUser(user: User) {
return { return {
email: user.email, 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 { try {
const hashedPassword = await bcrypt.hash(password, saltRounds); const hashedPassword = await bcrypt.hash(password, saltRounds);
const user = await dataAccess.client.user.create({ const user = await dataAccess.client.user.create({
data: { data: {
email, email,
password: hashedPassword password: hashedPassword,
userGroupId
} }
}); });
return user; return user;
@@ -84,13 +116,38 @@ export class UserService {
} }
} }
async getAllUsers() { async getAllUsers(): Promise<UserExtended[]> {
return await unstable_cache(async () => await dataAccess.client.user.findMany(), 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.users()], {
tags: [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) { async getUserByEmail(email: string) {
return await dataAccess.client.user.findFirstOrThrow({ return await dataAccess.client.user.findFirstOrThrow({
where: { where: {
@@ -192,6 +249,22 @@ export class UserService {
revalidateTag(Tags.users()); 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(); const userService = new UserService();

View File

@@ -1,5 +1,5 @@
import { ServiceException } from "@/shared/model/service.exception.model"; 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 { getServerSession } from "next-auth";
import { ZodRawShape, ZodObject, objectUtil, baseObjectOutputType, z, ZodType } from "zod"; import { ZodRawShape, ZodObject, objectUtil, baseObjectOutputType, z, ZodType } from "zod";
import { redirect } from "next/navigation"; 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 { FormValidationException } from "@/shared/model/form-validation-exception.model";
import { authOptions } from "@/server/utils/auth-options"; import { authOptions } from "@/server/utils/auth-options";
import { NextResponse } from "next/server"; 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 * THIS FUNCTION RETURNS NULL IF NO USER IS LOGGED IN
@@ -17,8 +20,13 @@ export async function getUserSession(): Promise<UserSession | null> {
if (!session) { if (!session) {
return null; return null;
} }
let userGroup: UserGroupExtended | null = null;
if (!!session?.user?.email) {
userGroup = await userGroupService.getRoleByUserMail(session.user.email);
}
return { 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; 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>( export async function saveFormAction<ReturnType, TInputData, ZodType extends ZodRawShape>(
inputData: TInputData, inputData: TInputData,
validationModel: ZodObject<ZodType>, validationModel: ZodObject<ZodType>,

View File

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

View File

@@ -1,5 +1,6 @@
import { z } from "zod"; import { z } from "zod";
import { AppBasicAuthModel, AppDomainModel, AppFileMountModel, AppModel, AppPortModel, AppVolumeModel, ProjectModel, VolumeBackupModel } from "./generated-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({ export const AppExtendedZodModel= z.lazy(() => AppModel.extend({
project: ProjectModel, project: ProjectModel,
@@ -11,3 +12,7 @@ export const AppExtendedZodModel= z.lazy(() => AppModel.extend({
})) }))
export type AppExtendedModel = z.infer<typeof AppExtendedZodModel>; 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 * 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({ export const AppModel = z.object({
id: z.string(), id: z.string(),
@@ -34,6 +34,7 @@ export interface CompleteApp extends z.infer<typeof AppModel> {
appVolumes: CompleteAppVolume[] appVolumes: CompleteAppVolume[]
appFileMounts: CompleteAppFileMount[] appFileMounts: CompleteAppFileMount[]
appBasicAuths: CompleteAppBasicAuth[] appBasicAuths: CompleteAppBasicAuth[]
roleAppPermissions: CompleteRoleAppPermission[]
} }
/** /**
@@ -48,4 +49,5 @@ export const RelatedAppModel: z.ZodSchema<CompleteApp> = z.lazy(() => AppModel.e
appVolumes: RelatedAppVolumeModel.array(), appVolumes: RelatedAppVolumeModel.array(),
appFileMounts: RelatedAppFileMountModel.array(), appFileMounts: RelatedAppFileMountModel.array(),
appBasicAuths: RelatedAppBasicAuthModel.array(), appBasicAuths: RelatedAppBasicAuthModel.array(),
roleAppPermissions: RelatedRoleAppPermissionModel.array(),
})) }))

View File

@@ -3,6 +3,9 @@ export * from "./session"
export * from "./user" export * from "./user"
export * from "./verificationtoken" export * from "./verificationtoken"
export * from "./authenticator" export * from "./authenticator"
export * from "./usergroup"
export * from "./roleprojectpermission"
export * from "./roleapppermission"
export * from "./project" export * from "./project"
export * from "./app" export * from "./app"
export * from "./appport" export * from "./appport"

View File

@@ -1,6 +1,6 @@
import * as z from "zod" import * as z from "zod"
import { CompleteApp, RelatedAppModel } from "./index" import { CompleteApp, RelatedAppModel, CompleteRoleProjectPermission, RelatedRoleProjectPermissionModel } from "./index"
export const ProjectModel = z.object({ export const ProjectModel = z.object({
id: z.string(), id: z.string(),
@@ -11,6 +11,7 @@ export const ProjectModel = z.object({
export interface CompleteProject extends z.infer<typeof ProjectModel> { export interface CompleteProject extends z.infer<typeof ProjectModel> {
apps: CompleteApp[] 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({ export const RelatedProjectModel: z.ZodSchema<CompleteProject> = z.lazy(() => ProjectModel.extend({
apps: RelatedAppModel.array(), 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 * 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({ export const UserModel = z.object({
id: z.string(), id: z.string(),
@@ -11,11 +11,13 @@ export const UserModel = z.object({
twoFaSecret: z.string().nullish(), twoFaSecret: z.string().nullish(),
twoFaEnabled: z.boolean(), twoFaEnabled: z.boolean(),
image: z.string().nullish(), image: z.string().nullish(),
userGroupId: z.string().nullish(),
createdAt: z.date(), createdAt: z.date(),
updatedAt: z.date(), updatedAt: z.date(),
}) })
export interface CompleteUser extends z.infer<typeof UserModel> { export interface CompleteUser extends z.infer<typeof UserModel> {
userGroup?: CompleteUserGroup | null
accounts: CompleteAccount[] accounts: CompleteAccount[]
sessions: CompleteSession[] sessions: CompleteSession[]
Authenticator: CompleteAuthenticator[] 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 * NOTE: Lazy required in case of potential circular dependencies within schema
*/ */
export const RelatedUserModel: z.ZodSchema<CompleteUser> = z.lazy(() => UserModel.extend({ export const RelatedUserModel: z.ZodSchema<CompleteUser> = z.lazy(() => UserModel.extend({
userGroup: RelatedUserGroupModel.nullish(),
accounts: RelatedAccountModel.array(), accounts: RelatedAccountModel.array(),
sessions: RelatedSessionModel.array(), sessions: RelatedSessionModel.array(),
Authenticator: RelatedAuthenticatorModel.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 { Session } from "next-auth";
import { RolePermissionEnum } from "./role-extended.model.ts";
export interface UserSession { export interface UserSession {
email: string; 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;
}
}