mirror of
https://github.com/biersoeckli/QuickStack.git
synced 2026-02-10 21:49:19 -06:00
feat: implement read-only access control for app settings and enhance role permissions validation for server actions
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import k3s from "@/server/adapter/kubernetes-api.adapter";
|
||||
import appService from "@/server/services/app.service";
|
||||
import deploymentService from "@/server/services/deployment.service";
|
||||
import { getAuthUserSession, simpleRoute } from "@/server/utils/action-wrapper.utils";
|
||||
import { getAuthUserSession, isAuthorizedReadForApp, simpleRoute } from "@/server/utils/action-wrapper.utils";
|
||||
import { Informer, V1Pod } from "@kubernetes/client-node";
|
||||
import { z } from "zod";
|
||||
import * as k8s from '@kubernetes/client-node';
|
||||
@@ -18,6 +18,7 @@ export async function POST(request: Request) {
|
||||
const input = await request.json();
|
||||
const podInfo = zodInputModel.parse(input);
|
||||
let { appId } = podInfo;
|
||||
await isAuthorizedReadForApp(appId);
|
||||
|
||||
const app = await appService.getById(appId);
|
||||
const namespace = app.projectId;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { FsUtils } from "@/server/utils/fs.utils";
|
||||
import { PathUtils } from "@/server/utils/path.utils";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import fs from 'fs/promises';
|
||||
import { getAuthUserSession } from "@/server/utils/action-wrapper.utils";
|
||||
import { getAuthUserSession, isAuthorizedReadForApp } from "@/server/utils/action-wrapper.utils";
|
||||
import { ServiceException } from "@/shared/model/service.exception.model";
|
||||
import { z } from "zod";
|
||||
import { stringToDate } from "@/shared/utils/zod.utils";
|
||||
@@ -25,6 +25,8 @@ export async function GET(request: NextRequest) {
|
||||
const date = requestUrl.searchParams.get('date');
|
||||
const validatedData = zodInputModel.parse({ appId, date });
|
||||
|
||||
await isAuthorizedReadForApp(validatedData.appId);
|
||||
|
||||
const logsPath = PathUtils.appLogsFile(validatedData.appId, validatedData.date);
|
||||
if (!await FsUtils.fileExists(logsPath)) {
|
||||
throw new ServiceException(`Could not find logs for ${appId}.`);
|
||||
|
||||
@@ -3,20 +3,20 @@
|
||||
import { SuccessActionResult } from "@/shared/model/server-action-error-return.model";
|
||||
import appService from "@/server/services/app.service";
|
||||
import deploymentService from "@/server/services/deployment.service";
|
||||
import { getAuthUserSession, simpleAction } from "@/server/utils/action-wrapper.utils";
|
||||
import { isAuthorizedReadForApp, isAuthorizedWriteForApp, simpleAction } from "@/server/utils/action-wrapper.utils";
|
||||
import eventService from "@/server/services/event.service";
|
||||
|
||||
|
||||
export const deploy = async (appId: string, forceBuild = false) =>
|
||||
simpleAction(async () => {
|
||||
await getAuthUserSession();
|
||||
await isAuthorizedWriteForApp(appId);
|
||||
await appService.buildAndDeploy(appId, forceBuild);
|
||||
return new SuccessActionResult(undefined, 'Successfully started deployment.');
|
||||
});
|
||||
|
||||
export const stopApp = async (appId: string) =>
|
||||
simpleAction(async () => {
|
||||
await getAuthUserSession();
|
||||
await isAuthorizedWriteForApp(appId);
|
||||
const app = await appService.getExtendedById(appId);
|
||||
await deploymentService.setReplicasForDeployment(app.projectId, app.id, 0);
|
||||
return new SuccessActionResult(undefined, 'Successfully stopped app.');
|
||||
@@ -24,7 +24,7 @@ export const stopApp = async (appId: string) =>
|
||||
|
||||
export const startApp = async (appId: string) =>
|
||||
simpleAction(async () => {
|
||||
await getAuthUserSession();
|
||||
await isAuthorizedWriteForApp(appId);
|
||||
const app = await appService.getExtendedById(appId);
|
||||
await deploymentService.setReplicasForDeployment(app.projectId, app.id, app.replicas);
|
||||
return new SuccessActionResult(undefined, 'Successfully started app.');
|
||||
@@ -32,7 +32,7 @@ export const startApp = async (appId: string) =>
|
||||
|
||||
export const getLatestAppEvents = async (appId: string) =>
|
||||
simpleAction(async () => {
|
||||
await getAuthUserSession();
|
||||
await isAuthorizedReadForApp(appId);
|
||||
const app = await appService.getById(appId);
|
||||
return await eventService.getEventsForApp(app.projectId, app.id);
|
||||
});
|
||||
|
||||
@@ -1,24 +1,14 @@
|
||||
'use server'
|
||||
|
||||
import { appVolumeEditZodModel } from "@/shared/model/volume-edit.model";
|
||||
import { ServerActionResult, SuccessActionResult } from "@/shared/model/server-action-error-return.model";
|
||||
import { SuccessActionResult } from "@/shared/model/server-action-error-return.model";
|
||||
import appService from "@/server/services/app.service";
|
||||
import { getAuthUserSession, saveFormAction, simpleAction } from "@/server/utils/action-wrapper.utils";
|
||||
import { z } from "zod";
|
||||
import { ServiceException } from "@/shared/model/service.exception.model";
|
||||
import pvcService from "@/server/services/pvc.service";
|
||||
import { fileMountEditZodModel } from "@/shared/model/file-mount-edit.model";
|
||||
import { VolumeBackupEditModel, volumeBackupEditZodModel } from "@/shared/model/backup-volume-edit.model";
|
||||
import volumeBackupService from "@/server/services/volume-backup.service";
|
||||
import backupService from "@/server/services/standalone-services/backup.service";
|
||||
import { volumeUploadZodModel } from "@/shared/model/volume-upload.model";
|
||||
import restoreService from "@/server/services/restore.service";
|
||||
import { isAuthorizedWriteForApp, saveFormAction, simpleAction } from "@/server/utils/action-wrapper.utils";
|
||||
import { BasicAuthEditModel, basicAuthEditZodModel } from "@/shared/model/basic-auth-edit.model";
|
||||
|
||||
|
||||
export const saveBasicAuth = async (prevState: any, inputData: BasicAuthEditModel) =>
|
||||
saveFormAction(inputData, basicAuthEditZodModel, async (validatedData) => {
|
||||
await getAuthUserSession();
|
||||
await isAuthorizedWriteForApp(validatedData.appId);
|
||||
|
||||
await appService.saveBasicAuth({
|
||||
...validatedData,
|
||||
@@ -30,7 +20,7 @@ export const saveBasicAuth = async (prevState: any, inputData: BasicAuthEditMode
|
||||
|
||||
export const deleteBasicAuth = async (basicAuthId: string) =>
|
||||
simpleAction(async () => {
|
||||
await getAuthUserSession();
|
||||
await isAuthorizedWriteForApp(await appService.getBasicAuthById(basicAuthId).then(b => b.appId));
|
||||
await appService.deleteBasicAuthById(basicAuthId);
|
||||
return new SuccessActionResult(undefined, 'Successfully deleted item');
|
||||
});
|
||||
|
||||
@@ -13,8 +13,9 @@ import BasicAuthEditDialog from "./basic-auth-edit-dialog";
|
||||
import { deleteBasicAuth } from "./actions";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
|
||||
export default function BasicAuth({ app }: {
|
||||
app: AppExtendedModel
|
||||
export default function BasicAuth({ app, readonly }: {
|
||||
app: AppExtendedModel;
|
||||
readonly: boolean;
|
||||
}) {
|
||||
|
||||
const { openConfirmDialog: openDialog } = useConfirmDialog();
|
||||
@@ -64,24 +65,24 @@ export default function BasicAuth({ app }: {
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium flex gap-2">
|
||||
{!readonly && <TableCell className="font-medium flex gap-2">
|
||||
<BasicAuthEditDialog app={app} basicAuth={basicAuth}>
|
||||
<Button variant="ghost"><EditIcon /></Button>
|
||||
</BasicAuthEditDialog>
|
||||
<Button variant="ghost" onClick={() => asyncDelete(basicAuth.id)}>
|
||||
<TrashIcon />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableCell>}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
{!readonly && <CardFooter>
|
||||
<FileMountEditDialog app={app}>
|
||||
<Button>Add Auth Credential</Button>
|
||||
</FileMountEditDialog>
|
||||
</CardFooter>
|
||||
</CardFooter>}
|
||||
</Card >
|
||||
</>;
|
||||
}
|
||||
@@ -7,24 +7,29 @@ import { AppExtendedModel } from "@/shared/model/app-extended.model";
|
||||
import { Toast } from "@/frontend/utils/toast.utils";
|
||||
import AppStatus from "./app-status";
|
||||
import { ExternalLink, Hammer, Pause, Play, Rocket } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { AppEventsDialog } from "./app-events-dialog";
|
||||
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
|
||||
import { UserSession } from "@/shared/model/sim-session.model";
|
||||
import { RoleUtils } from "@/server/utils/role.utils";
|
||||
|
||||
export default function AppActionButtons({
|
||||
app
|
||||
app,
|
||||
session
|
||||
}: {
|
||||
app: AppExtendedModel;
|
||||
session: UserSession;
|
||||
}) {
|
||||
const hasWriteAccess = RoleUtils.sessionHasWriteAccessForApp(session, app.id);
|
||||
return <Card>
|
||||
<CardContent className="p-4 ">
|
||||
<ScrollArea>
|
||||
<div className="flex gap-4">
|
||||
<div className="self-center"><AppEventsDialog app={app}><AppStatus appId={app.id} /></AppEventsDialog></div>
|
||||
<Button onClick={() => Toast.fromAction(() => deploy(app.id))}><Rocket /> Deploy</Button>
|
||||
<Button onClick={() => Toast.fromAction(() => deploy(app.id, true))} variant="secondary"><Hammer /> Rebuild</Button>
|
||||
<Button onClick={() => Toast.fromAction(() => startApp(app.id))} variant="secondary"><Play />Start</Button>
|
||||
<Button onClick={() => Toast.fromAction(() => stopApp(app.id))} variant="secondary"><Pause /> Stop</Button>
|
||||
{hasWriteAccess && <><Button onClick={() => Toast.fromAction(() => deploy(app.id))}><Rocket /> Deploy</Button>
|
||||
<Button onClick={() => Toast.fromAction(() => deploy(app.id, true))} variant="secondary"><Hammer /> Rebuild</Button>
|
||||
<Button onClick={() => Toast.fromAction(() => startApp(app.id))} variant="secondary"><Play />Start</Button>
|
||||
<Button onClick={() => Toast.fromAction(() => stopApp(app.id))} variant="secondary"><Pause /> Stop</Button>
|
||||
</>}
|
||||
{app.appDomains.length > 0 && <Button onClick={() => {
|
||||
const domain = app.appDomains[0];
|
||||
const protocol = domain.useSsl ? 'https' : 'http';
|
||||
|
||||
@@ -21,29 +21,31 @@ import { VolumeBackupExtendedModel } from "@/shared/model/volume-backup-extended
|
||||
import BasicAuth from "./advanced/basic-auth";
|
||||
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
|
||||
import DbToolsCard from "./credentials/db-tools";
|
||||
import { RolePermissionEnum } from "@/shared/model/role-extended.model.ts";
|
||||
|
||||
export default function AppTabs({
|
||||
app,
|
||||
role,
|
||||
tabName,
|
||||
s3Targets,
|
||||
volumeBackups
|
||||
}: {
|
||||
app: AppExtendedModel;
|
||||
role: RolePermissionEnum;
|
||||
tabName: string;
|
||||
s3Targets: S3Target[],
|
||||
volumeBackups: VolumeBackupExtendedModel[]
|
||||
}) {
|
||||
const router = useRouter();
|
||||
|
||||
const readonly = role !== RolePermissionEnum.READWRITE;
|
||||
const openTab = (tabName: string) => {
|
||||
router.push(`/project/app/${app.id}?tabName=${tabName}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<Tabs defaultValue="general" value={tabName} onValueChange={(newTab) => openTab(newTab)} className="space-y-4">
|
||||
<ScrollArea >
|
||||
<ScrollArea>
|
||||
<TabsList>
|
||||
|
||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||
{app.appType !== 'APP' && <TabsTrigger value="credentials">Credentials</TabsTrigger>}
|
||||
<TabsTrigger value="general">General</TabsTrigger>
|
||||
@@ -56,35 +58,36 @@ export default function AppTabs({
|
||||
</ScrollArea>
|
||||
<TabsContent value="overview" className="grid grid-cols-1 3xl:grid-cols-2 gap-4">
|
||||
<MonitoringTab app={app} />
|
||||
<Logs app={app} />
|
||||
<BuildsTab app={app} />
|
||||
<WebhookDeploymentInfo app={app} />
|
||||
<Logs role={role} app={app} />
|
||||
<BuildsTab role={role} app={app} />
|
||||
<WebhookDeploymentInfo role={role} app={app} />
|
||||
</TabsContent>
|
||||
{app.appType !== 'APP' && <TabsContent value="credentials" className="space-y-4">
|
||||
<DbToolsCard app={app} />
|
||||
{role === RolePermissionEnum.READWRITE && <DbToolsCard app={app} />}
|
||||
<DbCredentials app={app} />
|
||||
</TabsContent>}
|
||||
<TabsContent value="general" className="space-y-4">
|
||||
<GeneralAppSource app={app} />
|
||||
<GeneralAppRateLimits app={app} />
|
||||
<GeneralAppSource readonly={readonly} app={app} />
|
||||
<GeneralAppRateLimits readonly={readonly} app={app} />
|
||||
</TabsContent>
|
||||
<TabsContent value="environment" className="space-y-4">
|
||||
<EnvEdit app={app} />
|
||||
<EnvEdit readonly={readonly} app={app} />
|
||||
</TabsContent>
|
||||
<TabsContent value="domains" className="space-y-4">
|
||||
<DomainsList app={app} />
|
||||
<InternalHostnames app={app} />
|
||||
<DomainsList readonly={readonly} app={app} />
|
||||
<InternalHostnames readonly={readonly} app={app} />
|
||||
</TabsContent>
|
||||
<TabsContent value="storage" className="space-y-4">
|
||||
<StorageList app={app} />
|
||||
<FileMount app={app} />
|
||||
<StorageList readonly={readonly} app={app} />
|
||||
<FileMount readonly={readonly} app={app} />
|
||||
<VolumeBackupList
|
||||
readonly={readonly}
|
||||
app={app}
|
||||
s3Targets={s3Targets}
|
||||
volumeBackups={volumeBackups} />
|
||||
</TabsContent>
|
||||
<TabsContent value="advanced" className="space-y-4">
|
||||
<BasicAuth app={app} />
|
||||
<BasicAuth readonly={readonly} app={app} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)
|
||||
|
||||
@@ -4,7 +4,7 @@ import appService from "@/server/services/app.service";
|
||||
import dbGateService from "@/server/services/db-tool-services/dbgate.service";
|
||||
import pgAdminService from "@/server/services/db-tool-services/pgadmin.service";
|
||||
import phpMyAdminService from "@/server/services/db-tool-services/phpmyadmin.service";
|
||||
import { getAuthUserSession, simpleAction } from "@/server/utils/action-wrapper.utils";
|
||||
import { isAuthorizedReadForApp, isAuthorizedWriteForApp, simpleAction } from "@/server/utils/action-wrapper.utils";
|
||||
import { AppTemplateUtils } from "@/server/utils/app-template.utils";
|
||||
import { DatabaseTemplateInfoModel } from "@/shared/model/database-template-info.model";
|
||||
import { ServerActionResult, SuccessActionResult } from "@/shared/model/server-action-error-return.model";
|
||||
@@ -20,7 +20,7 @@ const dbToolClasses = new Map([
|
||||
|
||||
export const getDatabaseCredentials = async (appId: string) =>
|
||||
simpleAction(async () => {
|
||||
await getAuthUserSession();
|
||||
await isAuthorizedReadForApp(appId);
|
||||
const app = await appService.getExtendedById(appId);
|
||||
const credentials = AppTemplateUtils.getDatabaseModelFromApp(app);
|
||||
return new SuccessActionResult(credentials);
|
||||
@@ -28,7 +28,7 @@ export const getDatabaseCredentials = async (appId: string) =>
|
||||
|
||||
export const getIsDbToolActive = async (appId: string, dbTool: DbToolIds) =>
|
||||
simpleAction(async () => {
|
||||
await getAuthUserSession();
|
||||
await isAuthorizedWriteForApp(appId);
|
||||
if (!dbToolClasses.has(dbTool)) {
|
||||
throw new ServiceException('Unknown db tool');
|
||||
}
|
||||
@@ -38,7 +38,7 @@ export const getIsDbToolActive = async (appId: string, dbTool: DbToolIds) =>
|
||||
|
||||
export const deployDbTool = async (appId: string, dbTool: DbToolIds) =>
|
||||
simpleAction(async () => {
|
||||
await getAuthUserSession();
|
||||
await isAuthorizedWriteForApp(appId);
|
||||
|
||||
const currentDbTool = dbToolClasses.get(dbTool);
|
||||
if (!currentDbTool) {
|
||||
@@ -51,7 +51,7 @@ export const deployDbTool = async (appId: string, dbTool: DbToolIds) =>
|
||||
|
||||
export const getLoginCredentialsForRunningDbTool = async (appId: string, dbTool: DbToolIds) =>
|
||||
simpleAction(async () => {
|
||||
await getAuthUserSession();
|
||||
await isAuthorizedWriteForApp(appId);
|
||||
|
||||
const currentDbTool = dbToolClasses.get(dbTool);
|
||||
if (!currentDbTool) {
|
||||
@@ -63,7 +63,7 @@ export const getLoginCredentialsForRunningDbTool = async (appId: string, dbTool:
|
||||
|
||||
export const deleteDbToolDeploymentForAppIfExists = async (appId: string, dbTool: DbToolIds) =>
|
||||
simpleAction(async () => {
|
||||
await getAuthUserSession();
|
||||
await isAuthorizedWriteForApp(appId);
|
||||
|
||||
const currentDbTool = dbToolClasses.get(dbTool);
|
||||
if (!currentDbTool) {
|
||||
@@ -76,7 +76,7 @@ export const deleteDbToolDeploymentForAppIfExists = async (appId: string, dbTool
|
||||
|
||||
export const downloadDbGateFilesForApp = async (appId: string) =>
|
||||
simpleAction(async () => {
|
||||
await getAuthUserSession();
|
||||
await isAuthorizedWriteForApp(appId);
|
||||
const url = await dbGateService.downloadDbGateFilesForApp(appId);
|
||||
return new SuccessActionResult(url);
|
||||
}) as Promise<ServerActionResult<unknown, string>>;
|
||||
@@ -4,7 +4,7 @@ import { AppPortModel, appPortZodModel } from "@/shared/model/default-port.model
|
||||
import { appDomainEditZodModel } from "@/shared/model/domain-edit.model";
|
||||
import { SuccessActionResult } from "@/shared/model/server-action-error-return.model";
|
||||
import appService from "@/server/services/app.service";
|
||||
import { getAuthUserSession, saveFormAction, simpleAction } from "@/server/utils/action-wrapper.utils";
|
||||
import { getAuthUserSession, isAuthorizedWriteForApp, saveFormAction, simpleAction } from "@/server/utils/action-wrapper.utils";
|
||||
import { z } from "zod";
|
||||
import { TraefikMeUtils } from "@/shared/utils/traefik-me.utils";
|
||||
import { ServiceException } from "@/shared/model/service.exception.model";
|
||||
@@ -16,7 +16,7 @@ const actionAppDomainEditZodModel = appDomainEditZodModel.merge(z.object({
|
||||
|
||||
export const saveDomain = async (prevState: any, inputData: z.infer<typeof actionAppDomainEditZodModel>) =>
|
||||
saveFormAction(inputData, actionAppDomainEditZodModel, async (validatedData) => {
|
||||
await getAuthUserSession();
|
||||
await isAuthorizedWriteForApp(validatedData.appId);
|
||||
|
||||
if (validatedData.hostname.includes('://')) {
|
||||
const url = new URL(validatedData.hostname);
|
||||
@@ -37,14 +37,14 @@ export const saveDomain = async (prevState: any, inputData: z.infer<typeof actio
|
||||
|
||||
export const deleteDomain = async (domainId: string) =>
|
||||
simpleAction(async () => {
|
||||
await getAuthUserSession();
|
||||
await isAuthorizedWriteForApp(await appService.getDomainById(domainId).then(d => d.appId));
|
||||
await appService.deleteDomainById(domainId);
|
||||
return new SuccessActionResult(undefined, 'Successfully deleted domain');
|
||||
});
|
||||
|
||||
export const savePort = async (prevState: any, inputData: AppPortModel, appId: string, portId?: string) =>
|
||||
saveFormAction(inputData, appPortZodModel, async (validatedData) => {
|
||||
await getAuthUserSession();
|
||||
await isAuthorizedWriteForApp(appId);
|
||||
await appService.savePort({
|
||||
...validatedData,
|
||||
id: portId ?? undefined,
|
||||
@@ -54,7 +54,7 @@ export const savePort = async (prevState: any, inputData: AppPortModel, appId: s
|
||||
|
||||
export const deletePort = async (portId: string) =>
|
||||
simpleAction(async () => {
|
||||
await getAuthUserSession();
|
||||
await isAuthorizedWriteForApp(await appService.getPortById(portId).then(p => p.appId));
|
||||
await appService.deletePortById(portId);
|
||||
return new SuccessActionResult(undefined, 'Successfully deleted port');
|
||||
});
|
||||
@@ -13,8 +13,9 @@ import { OpenInNewWindowIcon } from "@radix-ui/react-icons";
|
||||
import { useConfirmDialog } from "@/frontend/states/zustand.states";
|
||||
|
||||
|
||||
export default function DomainsList({ app }: {
|
||||
app: AppExtendedModel
|
||||
export default function DomainsList({ app, readonly }: {
|
||||
app: AppExtendedModel;
|
||||
readonly: boolean;
|
||||
}) {
|
||||
|
||||
const { openConfirmDialog: openDialog } = useConfirmDialog();
|
||||
@@ -61,24 +62,24 @@ export default function DomainsList({ app }: {
|
||||
<TableCell className="font-medium">{domain.port}</TableCell>
|
||||
<TableCell className="font-medium">{domain.useSsl ? <CheckIcon /> : <XIcon />}</TableCell>
|
||||
<TableCell className="font-medium">{domain.useSsl && domain.redirectHttps ? <CheckIcon /> : <XIcon />}</TableCell>
|
||||
<TableCell className="font-medium flex gap-2">
|
||||
{!readonly && <TableCell className="font-medium flex gap-2">
|
||||
<DialogEditDialog appId={app.id} domain={domain}>
|
||||
<Button variant="ghost"><EditIcon /></Button>
|
||||
</DialogEditDialog>
|
||||
<Button variant="ghost" onClick={() => asyncDeleteDomain(domain.id)}>
|
||||
<TrashIcon />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableCell>}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
{!readonly && <CardFooter>
|
||||
<DialogEditDialog appId={app.id}>
|
||||
<Button><Plus /> Add Domain</Button>
|
||||
</DialogEditDialog>
|
||||
</CardFooter>
|
||||
</CardFooter>}
|
||||
</Card >
|
||||
|
||||
</>;
|
||||
|
||||
@@ -15,8 +15,9 @@ import { EditIcon, Plus, TrashIcon } from "lucide-react";
|
||||
import { Toast } from "@/frontend/utils/toast.utils";
|
||||
import { useConfirmDialog } from "@/frontend/states/zustand.states";
|
||||
|
||||
export default function InternalHostnames({ app }: {
|
||||
app: AppExtendedModel
|
||||
export default function InternalHostnames({ app, readonly }: {
|
||||
app: AppExtendedModel;
|
||||
readonly: boolean;
|
||||
}) {
|
||||
|
||||
const { openConfirmDialog: openDialog } = useConfirmDialog();
|
||||
@@ -34,7 +35,6 @@ export default function InternalHostnames({ app }: {
|
||||
|
||||
const internalUrl = KubeObjectNameUtils.toServiceName(app.id);
|
||||
|
||||
|
||||
return <>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -42,38 +42,38 @@ export default function InternalHostnames({ app }: {
|
||||
<CardDescription>If you want to connect other apps to this app, you have to configure the internal ports below.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableCaption>{app.appPorts.length} Ports</TableCaption>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Port</TableHead>
|
||||
<TableHead className="w-[100px]">Action</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{app.appPorts.map(port => (
|
||||
<TableRow key={port.id}>
|
||||
<TableCell className="font-medium">
|
||||
{port.port}
|
||||
</TableCell>
|
||||
<TableCell className="font-medium flex gap-2">
|
||||
<DefaultPortEditDialog appId={app.id} appPort={port}>
|
||||
<Button variant="ghost"><EditIcon /></Button>
|
||||
</DefaultPortEditDialog>
|
||||
<Button variant="ghost" onClick={() => asyncDeleteDomain(port.id)}>
|
||||
<TrashIcon />
|
||||
</Button>
|
||||
</TableCell>
|
||||
<Table>
|
||||
<TableCaption>{app.appPorts.length} Ports</TableCaption>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Port</TableHead>
|
||||
<TableHead className="w-[100px]">Action</TableHead>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{app.appPorts.map(port => (
|
||||
<TableRow key={port.id}>
|
||||
<TableCell className="font-medium">
|
||||
{port.port}
|
||||
</TableCell>
|
||||
{!readonly && <TableCell className="font-medium flex gap-2">
|
||||
<DefaultPortEditDialog appId={app.id} appPort={port}>
|
||||
<Button variant="ghost"><EditIcon /></Button>
|
||||
</DefaultPortEditDialog>
|
||||
<Button variant="ghost" onClick={() => asyncDeleteDomain(port.id)}>
|
||||
<TrashIcon />
|
||||
</Button>
|
||||
</TableCell>}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
{!readonly && <CardFooter>
|
||||
<DefaultPortEditDialog appId={app.id}>
|
||||
<Button><Plus /> Add Port</Button>
|
||||
</DefaultPortEditDialog>
|
||||
</CardFooter>
|
||||
</CardFooter>}
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
|
||||
import { AppEnvVariablesModel, appEnvVariablesZodModel } from "@/shared/model/env-edit.model";
|
||||
import appService from "@/server/services/app.service";
|
||||
import { getAuthUserSession, saveFormAction } from "@/server/utils/action-wrapper.utils";
|
||||
import { getAuthUserSession, isAuthorizedWriteForApp, saveFormAction } from "@/server/utils/action-wrapper.utils";
|
||||
|
||||
|
||||
export const saveEnvVariables = async (prevState: any, inputData: AppEnvVariablesModel, appId: string) =>
|
||||
saveFormAction(inputData, appEnvVariablesZodModel, async (validatedData) => {
|
||||
await getAuthUserSession();
|
||||
await isAuthorizedWriteForApp(appId);
|
||||
const existingApp = await appService.getById(appId);
|
||||
await appService.save({
|
||||
...existingApp,
|
||||
|
||||
@@ -16,12 +16,14 @@ import { Textarea } from "@/components/ui/textarea";
|
||||
import { AppExtendedModel } from "@/shared/model/app-extended.model";
|
||||
|
||||
|
||||
export default function EnvEdit({ app }: {
|
||||
app: AppExtendedModel
|
||||
export default function EnvEdit({ app, readonly }: {
|
||||
app: AppExtendedModel;
|
||||
readonly: boolean;
|
||||
}) {
|
||||
const form = useForm<AppEnvVariablesModel>({
|
||||
resolver: zodResolver(appEnvVariablesZodModel),
|
||||
defaultValues: app
|
||||
defaultValues: app,
|
||||
disabled: readonly,
|
||||
});
|
||||
|
||||
const [state, formAction] = useFormState((state: ServerActionResult<any, any>, payload: AppEnvVariablesModel) => saveEnvVariables(state, payload, app.id), FormUtils.getInitialFormState<typeof appEnvVariablesZodModel>());
|
||||
@@ -63,9 +65,9 @@ export default function EnvEdit({ app }: {
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
{!readonly && <CardFooter>
|
||||
<SubmitButton>Save</SubmitButton>
|
||||
</CardFooter>
|
||||
</CardFooter>}
|
||||
</form>
|
||||
</Form >
|
||||
</Card >
|
||||
|
||||
@@ -7,13 +7,13 @@ import { ErrorActionResult, ServerActionResult, SuccessActionResult } from "@/sh
|
||||
import { ServiceException } from "@/shared/model/service.exception.model";
|
||||
import appService from "@/server/services/app.service";
|
||||
import userService from "@/server/services/user.service";
|
||||
import { getAuthUserSession, saveFormAction, simpleAction } from "@/server/utils/action-wrapper.utils";
|
||||
import { getAuthUserSession, isAuthorizedWriteForApp, saveFormAction, simpleAction } from "@/server/utils/action-wrapper.utils";
|
||||
|
||||
|
||||
export const saveGeneralAppSourceInfo = async (prevState: any, inputData: AppSourceInfoInputModel, appId: string) => {
|
||||
if (inputData.sourceType === 'GIT') {
|
||||
return saveFormAction(inputData, appSourceInfoGitZodModel, async (validatedData) => {
|
||||
await getAuthUserSession();
|
||||
await isAuthorizedWriteForApp(appId);
|
||||
const existingApp = await appService.getById(appId);
|
||||
await appService.save({
|
||||
...existingApp,
|
||||
@@ -24,7 +24,7 @@ export const saveGeneralAppSourceInfo = async (prevState: any, inputData: AppSou
|
||||
});
|
||||
} else if (inputData.sourceType === 'CONTAINER') {
|
||||
return saveFormAction(inputData, appSourceInfoContainerZodModel, async (validatedData) => {
|
||||
await getAuthUserSession();
|
||||
await isAuthorizedWriteForApp(appId);
|
||||
const existingApp = await appService.getById(appId);
|
||||
await appService.save({
|
||||
...existingApp,
|
||||
@@ -43,7 +43,7 @@ export const saveGeneralAppRateLimits = async (prevState: any, inputData: AppRat
|
||||
if (validatedData.replicas < 1) {
|
||||
throw new ServiceException('Replica Count must be at least 1');
|
||||
}
|
||||
await getAuthUserSession();
|
||||
await isAuthorizedWriteForApp(appId);
|
||||
|
||||
const extendedApp = await appService.getExtendedById(appId);
|
||||
if (extendedApp.appVolumes.some(v => v.accessMode === 'ReadWriteOnce') && validatedData.replicas > 1) {
|
||||
|
||||
@@ -21,12 +21,14 @@ import { AppExtendedModel } from "@/shared/model/app-extended.model";
|
||||
import { cn } from "@/frontend/utils/utils";
|
||||
|
||||
|
||||
export default function GeneralAppRateLimits({ app }: {
|
||||
app: AppExtendedModel
|
||||
export default function GeneralAppRateLimits({ app, readonly }: {
|
||||
app: AppExtendedModel;
|
||||
readonly: boolean;
|
||||
}) {
|
||||
const form = useForm<AppRateLimitsModel>({
|
||||
resolver: zodResolver(appRateLimitsZodModel),
|
||||
defaultValues: app
|
||||
defaultValues: app,
|
||||
disabled: readonly
|
||||
});
|
||||
|
||||
const [state, formAction] = useFormState((state: ServerActionResult<any, any>, payload: AppRateLimitsModel) => saveGeneralAppRateLimits(state, payload, app.id), FormUtils.getInitialFormState<typeof appRateLimitsZodModel>());
|
||||
@@ -125,10 +127,10 @@ export default function GeneralAppRateLimits({ app }: {
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="gap-4">
|
||||
{!readonly && <CardFooter className="gap-4">
|
||||
<SubmitButton>Save</SubmitButton>
|
||||
<p className="text-red-500">{state?.message}</p>
|
||||
</CardFooter>
|
||||
</CardFooter>}
|
||||
</form>
|
||||
</Form >
|
||||
</Card >
|
||||
|
||||
@@ -18,15 +18,17 @@ import { App } from "@prisma/client";
|
||||
import { toast } from "sonner";
|
||||
import { AppExtendedModel } from "@/shared/model/app-extended.model";
|
||||
|
||||
export default function GeneralAppSource({ app }: {
|
||||
app: AppExtendedModel
|
||||
export default function GeneralAppSource({ app, readonly }: {
|
||||
app: AppExtendedModel;
|
||||
readonly: boolean;
|
||||
}) {
|
||||
const form = useForm<AppSourceInfoInputModel>({
|
||||
resolver: zodResolver(appSourceInfoInputZodModel),
|
||||
defaultValues: {
|
||||
...app,
|
||||
sourceType: app.sourceType as 'GIT' | 'CONTAINER'
|
||||
}
|
||||
},
|
||||
disabled: readonly,
|
||||
});
|
||||
|
||||
const [state, formAction] = useFormState((state: ServerActionResult<any, any>, payload: AppSourceInfoInputModel) => saveGeneralAppSourceInfo(state, payload, app.id), FormUtils.getInitialFormState<typeof appSourceInfoInputZodModel>());
|
||||
@@ -197,10 +199,10 @@ export default function GeneralAppSource({ app }: {
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
<CardFooter className="gap-4">
|
||||
{!readonly && <CardFooter className="gap-4">
|
||||
<SubmitButton>Save</SubmitButton>
|
||||
<p className="text-red-500">{state?.message}</p>
|
||||
</CardFooter>
|
||||
</CardFooter>}
|
||||
</form>
|
||||
</Form >
|
||||
</Card >
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Inter } from "next/font/google";
|
||||
import { getAuthUserSession } from "@/server/utils/action-wrapper.utils";
|
||||
import { isAuthorizedReadForApp } from "@/server/utils/action-wrapper.utils";
|
||||
import appService from "@/server/services/app.service";
|
||||
import {
|
||||
Breadcrumb,
|
||||
@@ -19,11 +19,11 @@ export default async function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
|
||||
await getAuthUserSession();
|
||||
const appId = params?.appId;
|
||||
if (!appId) {
|
||||
return <p>Could not find app with id {appId}</p>
|
||||
}
|
||||
const session = await isAuthorizedReadForApp(appId);
|
||||
const app = await appService.getExtendedById(appId);
|
||||
|
||||
return (
|
||||
@@ -32,7 +32,7 @@ export default async function RootLayout({
|
||||
title={app.name}
|
||||
subtitle={`App ID: ${app.id}`}>
|
||||
</PageTitle>
|
||||
<AppActionButtons app={app} />
|
||||
<AppActionButtons session={session} app={app} />
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -8,55 +8,54 @@ import buildService from "@/server/services/build.service";
|
||||
import deploymentService from "@/server/services/deployment.service";
|
||||
import monitoringService from "@/server/services/monitoring.service";
|
||||
import podService from "@/server/services/pod.service";
|
||||
import { getAuthUserSession, simpleAction } from "@/server/utils/action-wrapper.utils";
|
||||
import { isAuthorizedReadForApp, isAuthorizedWriteForApp, simpleAction } from "@/server/utils/action-wrapper.utils";
|
||||
import { PodsResourceInfoModel } from "@/shared/model/pods-resource-info.model";
|
||||
import appLogsService from "@/server/services/standalone-services/app-logs.service";
|
||||
import { DownloadableAppLogsModel } from "@/shared/model/downloadable-app-logs.model";
|
||||
import { ServiceException } from "@/shared/model/service.exception.model";
|
||||
|
||||
|
||||
export const getDeploymentsAndBuildsForApp = async (appId: string) =>
|
||||
simpleAction(async () => {
|
||||
await getAuthUserSession();
|
||||
await isAuthorizedReadForApp(appId);
|
||||
const app = await appService.getExtendedById(appId);
|
||||
return await deploymentService.getDeploymentHistory(app.projectId, appId);
|
||||
}) as Promise<ServerActionResult<unknown, DeploymentInfoModel[]>>;
|
||||
|
||||
export const deleteBuild = async (buildName: string) =>
|
||||
simpleAction(async () => {
|
||||
await getAuthUserSession();
|
||||
await isAuthorizedWriteForApp(await buildService.getAppIdByBuildName(buildName));
|
||||
await buildService.deleteBuild(buildName);
|
||||
return new SuccessActionResult(undefined, 'Successfully stopped and deleted build.');
|
||||
}) as Promise<ServerActionResult<unknown, void>>;
|
||||
|
||||
export const getPodsForApp = async (appId: string) =>
|
||||
simpleAction(async () => {
|
||||
await getAuthUserSession();
|
||||
await isAuthorizedReadForApp(appId);
|
||||
const app = await appService.getExtendedById(appId);
|
||||
return await podService.getPodsForApp(app.projectId, appId);
|
||||
}) as Promise<ServerActionResult<unknown, PodsInfoModel[]>>;
|
||||
|
||||
export const getRessourceDataApp = async (projectId: string, appId: string) =>
|
||||
simpleAction(async () => {
|
||||
await getAuthUserSession();
|
||||
await isAuthorizedReadForApp(appId);
|
||||
return await monitoringService.getMonitoringForApp(projectId, appId);
|
||||
}) as Promise<ServerActionResult<unknown, PodsResourceInfoModel>>;
|
||||
|
||||
export const createNewWebhookUrl = async (appId: string) =>
|
||||
simpleAction(async () => {
|
||||
await getAuthUserSession();
|
||||
await isAuthorizedWriteForApp(appId);
|
||||
await appService.regenerateWebhookId(appId);
|
||||
});
|
||||
|
||||
export const getDownloadableLogs = async (appId: string) =>
|
||||
simpleAction(async () => {
|
||||
await getAuthUserSession();
|
||||
await isAuthorizedReadForApp(appId);
|
||||
return new SuccessActionResult(await appLogsService.getAvailableLogsForApp(appId));
|
||||
}) as Promise<ServerActionResult<unknown, DownloadableAppLogsModel[]>>;
|
||||
|
||||
export const exportLogsToFileForToday = async (appId: string) =>
|
||||
simpleAction(async () => {
|
||||
await getAuthUserSession();
|
||||
await isAuthorizedReadForApp(appId);
|
||||
const result = await appLogsService.writeAppLogsToDiskForApp(appId);
|
||||
if (!result) {
|
||||
throw new ServiceException('There are no logs available for today.');
|
||||
|
||||
@@ -12,11 +12,14 @@ import { DeploymentInfoModel } from "@/shared/model/deployment-info.model";
|
||||
import DeploymentStatusBadge from "./deployment-status-badge";
|
||||
import { BuildLogsDialog } from "./build-logs-overlay";
|
||||
import ShortCommitHash from "@/components/custom/short-commit-hash";
|
||||
import { RolePermissionEnum } from "@/shared/model/role-extended.model.ts";
|
||||
|
||||
export default function BuildsTab({
|
||||
app
|
||||
app,
|
||||
role
|
||||
}: {
|
||||
app: AppExtendedModel;
|
||||
role: RolePermissionEnum;
|
||||
}) {
|
||||
|
||||
const { openConfirmDialog: openDialog } = useConfirmDialog();
|
||||
@@ -89,7 +92,7 @@ export default function BuildsTab({
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-1"></div>
|
||||
{item.deploymentId && <Button variant="secondary" onClick={() => setSelectedDeploymentForLogs(item)}>Show Logs</Button>}
|
||||
{item.buildJobName && item.status === 'BUILDING' && <Button variant="destructive" onClick={() => deleteBuildClick(item.buildJobName!)}>Stop Build</Button>}
|
||||
{role === RolePermissionEnum.READWRITE && item.buildJobName && item.status === 'BUILDING' && <Button variant="destructive" onClick={() => deleteBuildClick(item.buildJobName!)}>Stop Build</Button>}
|
||||
</div>
|
||||
</>
|
||||
}}
|
||||
|
||||
@@ -13,11 +13,14 @@ import { Download, Expand, Terminal } from "lucide-react";
|
||||
import { TerminalDialog } from "./terminal-overlay";
|
||||
import { LogsDownloadOverlay } from "./logs-download-overlay";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { RolePermissionEnum } from "@/shared/model/role-extended.model.ts";
|
||||
|
||||
export default function Logs({
|
||||
app
|
||||
app,
|
||||
role
|
||||
}: {
|
||||
app: AppExtendedModel;
|
||||
role: RolePermissionEnum;
|
||||
}) {
|
||||
const [selectedPod, setSelectedPod] = useState<PodsInfoModel | undefined>(undefined);
|
||||
const [appPods, setAppPods] = useState<PodsInfoModel[] | undefined>(undefined);
|
||||
@@ -76,7 +79,7 @@ export default function Logs({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
{role === RolePermissionEnum.READWRITE && <div>
|
||||
<TerminalDialog terminalInfo={{
|
||||
podName: selectedPod.podName,
|
||||
containerName: selectedPod.containerName,
|
||||
@@ -86,7 +89,7 @@ export default function Logs({
|
||||
<Terminal /> Terminal
|
||||
</Button>
|
||||
</TerminalDialog>
|
||||
</div>
|
||||
</div>}
|
||||
<div>
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={300}>
|
||||
|
||||
@@ -7,11 +7,14 @@ import { useConfirmDialog } from "@/frontend/states/zustand.states";
|
||||
import { Toast } from "@/frontend/utils/toast.utils";
|
||||
import { ClipboardCopy } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { RolePermissionEnum } from "@/shared/model/role-extended.model.ts";
|
||||
|
||||
export default function WebhookDeploymentInfo({
|
||||
app
|
||||
app,
|
||||
role
|
||||
}: {
|
||||
app: AppExtendedModel;
|
||||
role: RolePermissionEnum;
|
||||
}) {
|
||||
const { openConfirmDialog } = useConfirmDialog();
|
||||
const [webhookUrl, setWebhookUrl] = useState<string | undefined>(undefined);
|
||||
@@ -52,7 +55,7 @@ export default function WebhookDeploymentInfo({
|
||||
{webhookUrl && <Button className="flex-1 truncate" variant="secondary" onClick={copyWebhookUrl}>
|
||||
<span className="truncate">{webhookUrl}</span> <ClipboardCopy />
|
||||
</Button>}
|
||||
<Button onClick={createNewWebhookUrlAsync} variant={webhookUrl ? 'ghost' : 'secondary'}>{webhookUrl ? 'Generate new Webhook URL' : 'Enable Webhook deployments'}</Button>
|
||||
{role === RolePermissionEnum.READWRITE && <Button onClick={createNewWebhookUrlAsync} variant={webhookUrl ? 'ghost' : 'secondary'}>{webhookUrl ? 'Generate new Webhook URL' : 'Enable Webhook deployments'}</Button>}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { getAuthUserSession } from "@/server/utils/action-wrapper.utils";
|
||||
import { getAuthUserSession, isAuthorizedReadForApp } from "@/server/utils/action-wrapper.utils";
|
||||
import appService from "@/server/services/app.service";
|
||||
import AppTabs from "./app-tabs";
|
||||
import AppBreadcrumbs from "./app-breadcrumbs";
|
||||
import s3TargetService from "@/server/services/s3-target.service";
|
||||
import volumeBackupService from "@/server/services/volume-backup.service";
|
||||
import { RoleUtils } from "@/server/utils/role.utils";
|
||||
|
||||
export default async function AppPage({
|
||||
searchParams,
|
||||
@@ -12,11 +13,12 @@ export default async function AppPage({
|
||||
searchParams?: { [key: string]: string | undefined };
|
||||
params: { appId: string }
|
||||
}) {
|
||||
await getAuthUserSession();
|
||||
const appId = params?.appId;
|
||||
if (!appId) {
|
||||
return <p>Could not find app with id {appId}</p>
|
||||
}
|
||||
const session = await isAuthorizedReadForApp(appId);
|
||||
const role = RoleUtils.getRolePermissionForApp(session, appId);
|
||||
const [app, s3Targets, volumeBackups] = await Promise.all([
|
||||
appService.getExtendedById(appId),
|
||||
s3TargetService.getAll(),
|
||||
@@ -25,6 +27,7 @@ export default async function AppPage({
|
||||
|
||||
return (<>
|
||||
<AppTabs
|
||||
role={role!}
|
||||
volumeBackups={volumeBackups}
|
||||
s3Targets={s3Targets}
|
||||
app={app}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { appVolumeEditZodModel } from "@/shared/model/volume-edit.model";
|
||||
import { ServerActionResult, SuccessActionResult } from "@/shared/model/server-action-error-return.model";
|
||||
import appService from "@/server/services/app.service";
|
||||
import { getAuthUserSession, saveFormAction, simpleAction } from "@/server/utils/action-wrapper.utils";
|
||||
import { getAuthUserSession, isAuthorizedReadForApp, isAuthorizedWriteForApp, saveFormAction, simpleAction } from "@/server/utils/action-wrapper.utils";
|
||||
import { z } from "zod";
|
||||
import { ServiceException } from "@/shared/model/service.exception.model";
|
||||
import pvcService from "@/server/services/pvc.service";
|
||||
@@ -15,6 +15,7 @@ import { volumeUploadZodModel } from "@/shared/model/volume-upload.model";
|
||||
import restoreService from "@/server/services/restore.service";
|
||||
import fileBrowserService from "@/server/services/file-browser-service";
|
||||
import monitoringService from "@/server/services/monitoring.service";
|
||||
import dataAccess from "@/server/adapter/db.client";
|
||||
|
||||
const actionAppVolumeEditZodModel = appVolumeEditZodModel.merge(z.object({
|
||||
appId: z.string(),
|
||||
@@ -23,7 +24,7 @@ const actionAppVolumeEditZodModel = appVolumeEditZodModel.merge(z.object({
|
||||
|
||||
export const restoreVolumeFromZip = async (prevState: any, inputData: FormData, volumeId: string) =>
|
||||
simpleAction(async () => {
|
||||
await getAuthUserSession();
|
||||
await validateVolumeWriteAuthorization(volumeId);
|
||||
const validatedData = volumeUploadZodModel.parse({
|
||||
volumeId,
|
||||
file: ''
|
||||
@@ -39,7 +40,7 @@ export const restoreVolumeFromZip = async (prevState: any, inputData: FormData,
|
||||
|
||||
export const saveVolume = async (prevState: any, inputData: z.infer<typeof actionAppVolumeEditZodModel>) =>
|
||||
saveFormAction(inputData, actionAppVolumeEditZodModel, async (validatedData) => {
|
||||
await getAuthUserSession();
|
||||
await isAuthorizedWriteForApp(validatedData.appId);
|
||||
const existingApp = await appService.getExtendedById(validatedData.appId);
|
||||
const existingVolume = validatedData.id ? await appService.getVolumeById(validatedData.id) : undefined;
|
||||
if (existingVolume && existingVolume.size > validatedData.size) {
|
||||
@@ -57,20 +58,20 @@ export const saveVolume = async (prevState: any, inputData: z.infer<typeof actio
|
||||
|
||||
export const deleteVolume = async (volumeId: string) =>
|
||||
simpleAction(async () => {
|
||||
await getAuthUserSession();
|
||||
await validateVolumeWriteAuthorization(volumeId);
|
||||
await appService.deleteVolumeById(volumeId);
|
||||
return new SuccessActionResult(undefined, 'Successfully deleted volume');
|
||||
});
|
||||
|
||||
export const getPvcUsage = async (appId: string, projectId: string) =>
|
||||
simpleAction(async () => {
|
||||
await getAuthUserSession();
|
||||
await isAuthorizedReadForApp(appId);
|
||||
return monitoringService.getPvcUsageFromApp(appId, projectId);
|
||||
}) as Promise<ServerActionResult<any, { pvcName: string, usedBytes: number }[]>>;
|
||||
|
||||
export const downloadPvcData = async (volumeId: string) =>
|
||||
simpleAction(async () => {
|
||||
await getAuthUserSession();
|
||||
await validateVolumeReadAuthorization(volumeId);
|
||||
const fileNameOfDownloadedFile = await pvcService.downloadPvcData(volumeId);
|
||||
return new SuccessActionResult(fileNameOfDownloadedFile, 'Successfully zipped volume data'); // returns the download path on the server
|
||||
}) as Promise<ServerActionResult<any, string>>;
|
||||
@@ -82,7 +83,7 @@ const actionAppFileMountEditZodModel = fileMountEditZodModel.merge(z.object({
|
||||
|
||||
export const saveFileMount = async (prevState: any, inputData: z.infer<typeof actionAppFileMountEditZodModel>) =>
|
||||
saveFormAction(inputData, actionAppFileMountEditZodModel, async (validatedData) => {
|
||||
await getAuthUserSession();
|
||||
await isAuthorizedWriteForApp(validatedData.appId);
|
||||
await appService.saveFileMount({
|
||||
...validatedData,
|
||||
id: validatedData.id ?? undefined,
|
||||
@@ -91,14 +92,14 @@ export const saveFileMount = async (prevState: any, inputData: z.infer<typeof ac
|
||||
|
||||
export const deleteFileMount = async (fileMountId: string) =>
|
||||
simpleAction(async () => {
|
||||
await getAuthUserSession();
|
||||
await validateFileMountWriteAuthorization(fileMountId);
|
||||
await appService.deleteFileMountById(fileMountId);
|
||||
return new SuccessActionResult(undefined, 'Successfully deleted volume');
|
||||
});
|
||||
|
||||
export const saveBackupVolume = async (prevState: any, inputData: VolumeBackupEditModel) =>
|
||||
saveFormAction(inputData, volumeBackupEditZodModel, async (validatedData) => {
|
||||
await getAuthUserSession();
|
||||
await validateVolumeWriteAuthorization(validatedData.volumeId);
|
||||
if (validatedData.retention < 1) {
|
||||
throw new ServiceException('Retention must be at least 1');
|
||||
}
|
||||
@@ -112,7 +113,7 @@ export const saveBackupVolume = async (prevState: any, inputData: VolumeBackupEd
|
||||
|
||||
export const deleteBackupVolume = async (backupVolumeId: string) =>
|
||||
simpleAction(async () => {
|
||||
await getAuthUserSession();
|
||||
await validateBackupVolumeWriteAuthorization(backupVolumeId);
|
||||
await volumeBackupService.deleteById(backupVolumeId);
|
||||
await backupService.registerAllBackups();
|
||||
return new SuccessActionResult(undefined, 'Successfully deleted backup schedule');
|
||||
@@ -120,17 +121,69 @@ export const deleteBackupVolume = async (backupVolumeId: string) =>
|
||||
|
||||
export const runBackupVolumeSchedule = async (backupVolumeId: string) =>
|
||||
simpleAction(async () => {
|
||||
await getAuthUserSession();
|
||||
await validateBackupVolumeWriteAuthorization(backupVolumeId);
|
||||
await backupService.runBackupForVolume(backupVolumeId);
|
||||
return new SuccessActionResult(undefined, 'Backup created and uploaded successfully');
|
||||
});
|
||||
|
||||
export const openFileBrowserForVolume = async (volumeId: string) =>
|
||||
simpleAction(async () => {
|
||||
await getAuthUserSession();
|
||||
await validateVolumeWriteAuthorization(volumeId);
|
||||
const fileBrowserDomain = await fileBrowserService.deployFileBrowserForVolume(volumeId);
|
||||
return new SuccessActionResult(fileBrowserDomain, 'File browser started successfully');
|
||||
}) as Promise<ServerActionResult<any, {
|
||||
url: string;
|
||||
password: string;
|
||||
}>>;
|
||||
}>>;
|
||||
|
||||
async function validateVolumeWriteAuthorization(volumeId: string) {
|
||||
const volumeAppId = await dataAccess.client.appVolume.findFirstOrThrow({
|
||||
where: {
|
||||
id: volumeId,
|
||||
},
|
||||
select: {
|
||||
appId: true,
|
||||
}
|
||||
});
|
||||
await isAuthorizedWriteForApp(volumeAppId?.appId);
|
||||
}
|
||||
|
||||
async function validateVolumeReadAuthorization(volumeId: string) {
|
||||
const volumeAppId = await dataAccess.client.appVolume.findFirstOrThrow({
|
||||
where: {
|
||||
id: volumeId,
|
||||
},
|
||||
select: {
|
||||
appId: true,
|
||||
}
|
||||
});
|
||||
await isAuthorizedReadForApp(volumeAppId?.appId);
|
||||
}
|
||||
|
||||
async function validateFileMountWriteAuthorization(fileMountId: string) {
|
||||
const fileMountAppId = await dataAccess.client.appFileMount.findFirstOrThrow({
|
||||
where: {
|
||||
id: fileMountId,
|
||||
},
|
||||
select: {
|
||||
appId: true,
|
||||
}
|
||||
});
|
||||
await isAuthorizedWriteForApp(fileMountAppId?.appId);
|
||||
}
|
||||
|
||||
async function validateBackupVolumeWriteAuthorization(backupVolumeId: string) {
|
||||
const volumeAppId = await dataAccess.client.volumeBackup.findFirstOrThrow({
|
||||
where: {
|
||||
id: backupVolumeId,
|
||||
},
|
||||
select: {
|
||||
volume: {
|
||||
select: {
|
||||
appId: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
await isAuthorizedWriteForApp(volumeAppId?.volume.appId);
|
||||
}
|
||||
@@ -14,8 +14,9 @@ import FileMountEditDialog from "./file-mount-edit-dialog";
|
||||
|
||||
type AppVolumeWithCapacity = (AppVolume & { capacity?: string });
|
||||
|
||||
export default function FileMount({ app }: {
|
||||
app: AppExtendedModel
|
||||
export default function FileMount({ app, readonly }: {
|
||||
app: AppExtendedModel;
|
||||
readonly: boolean;
|
||||
}) {
|
||||
|
||||
const { openConfirmDialog: openDialog } = useConfirmDialog();
|
||||
@@ -50,24 +51,25 @@ export default function FileMount({ app }: {
|
||||
{app.appFileMounts.map(fileMount => (
|
||||
<TableRow key={fileMount.containerMountPath}>
|
||||
<TableCell className="font-medium">{fileMount.containerMountPath}</TableCell>
|
||||
<TableCell className="font-medium flex gap-2">
|
||||
{!readonly && <TableCell className="font-medium flex gap-2">
|
||||
<FileMountEditDialog app={app} fileMount={fileMount}>
|
||||
<Button variant="ghost"><EditIcon /></Button>
|
||||
</FileMountEditDialog>
|
||||
<Button variant="ghost" onClick={() => asyncDeleteFileMount(fileMount.id)}>
|
||||
<TrashIcon />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableCell>}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
{!readonly && <CardFooter>
|
||||
<FileMountEditDialog app={app}>
|
||||
<Button>Add File Mount</Button>
|
||||
</FileMountEditDialog>
|
||||
</CardFooter>
|
||||
}
|
||||
</Card >
|
||||
</>;
|
||||
}
|
||||
@@ -25,8 +25,9 @@ import { Progress } from "@/components/ui/progress";
|
||||
|
||||
type AppVolumeWithCapacity = (AppVolume & { usedBytes?: number; capacityBytes?: number; usedPercentage?: number });
|
||||
|
||||
export default function StorageList({ app }: {
|
||||
app: AppExtendedModel
|
||||
export default function StorageList({ app, readonly }: {
|
||||
app: AppExtendedModel;
|
||||
readonly: boolean;
|
||||
}) {
|
||||
|
||||
const [volumesWithStorage, setVolumesWithStorage] = React.useState<AppVolumeWithCapacity[]>(app.appVolumes);
|
||||
@@ -181,7 +182,7 @@ export default function StorageList({ app }: {
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<TooltipProvider>
|
||||
{!readonly && <TooltipProvider>
|
||||
<Tooltip delayDuration={200}>
|
||||
<TooltipTrigger>
|
||||
<Button variant="ghost" onClick={() => openFileBrowserForVolumeAsync(volume.id)} disabled={isLoading}>
|
||||
@@ -192,7 +193,7 @@ export default function StorageList({ app }: {
|
||||
<p>View content of Volume</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</TooltipProvider>}
|
||||
{/*<StorageRestoreDialog app={app} volume={volume}>
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={200}>
|
||||
@@ -207,41 +208,43 @@ export default function StorageList({ app }: {
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</StorageRestoreDialog>*/}
|
||||
<DialogEditDialog app={app} volume={volume}>
|
||||
{!readonly && <>
|
||||
<DialogEditDialog app={app} volume={volume}>
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={200}>
|
||||
<TooltipTrigger>
|
||||
<Button variant="ghost" disabled={isLoading}><EditIcon /></Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Edit volume settings</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</DialogEditDialog>
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={200}>
|
||||
<TooltipTrigger>
|
||||
<Button variant="ghost" disabled={isLoading}><EditIcon /></Button>
|
||||
<Button variant="ghost" onClick={() => asyncDeleteVolume(volume.id)} disabled={isLoading}>
|
||||
<TrashIcon />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Edit volume settings</p>
|
||||
<p>Delete volume</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</DialogEditDialog>
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={200}>
|
||||
<TooltipTrigger>
|
||||
<Button variant="ghost" onClick={() => asyncDeleteVolume(volume.id)} disabled={isLoading}>
|
||||
<TrashIcon />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Delete volume</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</>}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
{!readonly && <CardFooter>
|
||||
<DialogEditDialog app={app}>
|
||||
<Button>Add Volume</Button>
|
||||
</DialogEditDialog>
|
||||
</CardFooter>
|
||||
</CardFooter>}
|
||||
</Card >
|
||||
</>;
|
||||
}
|
||||
@@ -17,11 +17,13 @@ import { VolumeBackupExtendedModel } from "@/shared/model/volume-backup-extended
|
||||
export default function VolumeBackupList({
|
||||
app,
|
||||
volumeBackups,
|
||||
s3Targets
|
||||
s3Targets,
|
||||
readonly
|
||||
}: {
|
||||
app: AppExtendedModel,
|
||||
s3Targets: S3Target[],
|
||||
volumeBackups: VolumeBackupExtendedModel[]
|
||||
volumeBackups: VolumeBackupExtendedModel[];
|
||||
readonly: boolean;
|
||||
}) {
|
||||
|
||||
const { openConfirmDialog: openDialog } = useConfirmDialog();
|
||||
@@ -79,7 +81,7 @@ export default function VolumeBackupList({
|
||||
<TableCell className="font-medium">{volumeBackup.retention}</TableCell>
|
||||
<TableCell className="font-medium">{volumeBackup.target.name}</TableCell>
|
||||
<TableCell className="font-medium">{formatDateTime(volumeBackup.createdAt)}</TableCell>
|
||||
<TableCell className="font-medium flex gap-2">
|
||||
{!readonly && <TableCell className="font-medium flex gap-2">
|
||||
<Button disabled={isLoading} variant="ghost" onClick={() => asyncRunBackupVolumeSchedule(volumeBackup.id)}>
|
||||
<Play />
|
||||
</Button>
|
||||
@@ -90,17 +92,17 @@ export default function VolumeBackupList({
|
||||
<Button disabled={isLoading} variant="ghost" onClick={() => asyncDeleteBackupVolume(volumeBackup.id)}>
|
||||
<TrashIcon />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableCell>}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
{!readonly && <CardFooter>
|
||||
<VolumeBackupEditDialog s3Targets={s3Targets} volumes={app.appVolumes}>
|
||||
<Button>Add Backup Schedule</Button>
|
||||
</VolumeBackupEditDialog>
|
||||
</CardFooter>
|
||||
</CardFooter>}
|
||||
</Card >
|
||||
</>;
|
||||
}
|
||||
@@ -27,8 +27,6 @@ export default async function ProjectPage() {
|
||||
const relevantProjectsForUser = data.filter((project) =>
|
||||
project.apps.some((app) => RoleUtils.sessionHasReadAccessForApp(session, app.id)));
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div className="flex-1 space-y-4 pt-6">
|
||||
<div className="flex gap-4">
|
||||
|
||||
@@ -208,6 +208,14 @@ class AppService {
|
||||
return savedItem;
|
||||
}
|
||||
|
||||
async getDomainById(id: string) {
|
||||
return await dataAccess.client.appDomain.findFirstOrThrow({
|
||||
where: {
|
||||
id
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async deleteDomainById(id: string) {
|
||||
const existingDomain = await dataAccess.client.appDomain.findFirst({
|
||||
where: {
|
||||
@@ -399,6 +407,14 @@ class AppService {
|
||||
return savedItem;
|
||||
}
|
||||
|
||||
async getPortById(portId: string) {
|
||||
return await dataAccess.client.appPort.findFirstOrThrow({
|
||||
where: {
|
||||
id: portId
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async deletePortById(id: string) {
|
||||
const existingPort = await dataAccess.client.appPort.findFirst({
|
||||
where: {
|
||||
@@ -469,6 +485,14 @@ class AppService {
|
||||
}
|
||||
}
|
||||
|
||||
async getBasicAuthById(id: string) {
|
||||
return await dataAccess.client.appBasicAuth.findFirstOrThrow({
|
||||
where: {
|
||||
id
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async getAll() {
|
||||
const apps = await dataAccess.client.app.findMany({
|
||||
orderBy: {
|
||||
|
||||
@@ -191,6 +191,23 @@ class BuildService {
|
||||
}
|
||||
}
|
||||
|
||||
async getBuildByName(buildName: string) {
|
||||
const jobs = await k3s.batch.listNamespacedJob(BUILD_NAMESPACE);
|
||||
return jobs.body.items.find((job) => job.metadata?.name === buildName);
|
||||
}
|
||||
|
||||
async getAppIdByBuildName(buildName: string) {
|
||||
const job = await this.getBuildByName(buildName);
|
||||
if (!job) {
|
||||
throw new ServiceException(`No build found with name ${buildName}`);
|
||||
}
|
||||
const appId = job.metadata?.annotations?.[Constants.QS_ANNOTATION_APP_ID];
|
||||
if (!appId) {
|
||||
throw new ServiceException(`No appId found for build ${buildName}`);
|
||||
}
|
||||
return appId;
|
||||
}
|
||||
|
||||
async deleteBuild(buildName: string) {
|
||||
await k3s.batch.deleteNamespacedJob(buildName, BUILD_NAMESPACE);
|
||||
console.log(`Deleted build job ${buildName}`);
|
||||
|
||||
@@ -6,6 +6,16 @@ export class RoleUtils {
|
||||
return (session.permissions?.find(app => app.appId === appId)?.permission ?? null) as RolePermissionEnum | null;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user