feat: implement read-only access control for app settings and enhance role permissions validation for server actions

This commit is contained in:
biersoeckli
2025-03-07 11:56:47 +00:00
parent 88e955d4b0
commit cd1da58106
30 changed files with 315 additions and 186 deletions

View File

@@ -1,7 +1,7 @@
import k3s from "@/server/adapter/kubernetes-api.adapter";
import appService from "@/server/services/app.service";
import deploymentService from "@/server/services/deployment.service";
import { getAuthUserSession, simpleRoute } from "@/server/utils/action-wrapper.utils";
import { getAuthUserSession, isAuthorizedReadForApp, simpleRoute } from "@/server/utils/action-wrapper.utils";
import { Informer, V1Pod } from "@kubernetes/client-node";
import { z } from "zod";
import * as k8s from '@kubernetes/client-node';
@@ -18,6 +18,7 @@ export async function POST(request: Request) {
const input = await request.json();
const podInfo = zodInputModel.parse(input);
let { appId } = podInfo;
await isAuthorizedReadForApp(appId);
const app = await appService.getById(appId);
const namespace = app.projectId;

View File

@@ -3,7 +3,7 @@ import { FsUtils } from "@/server/utils/fs.utils";
import { PathUtils } from "@/server/utils/path.utils";
import { NextRequest, NextResponse } from "next/server";
import fs from 'fs/promises';
import { getAuthUserSession } from "@/server/utils/action-wrapper.utils";
import { getAuthUserSession, isAuthorizedReadForApp } from "@/server/utils/action-wrapper.utils";
import { ServiceException } from "@/shared/model/service.exception.model";
import { z } from "zod";
import { stringToDate } from "@/shared/utils/zod.utils";
@@ -25,6 +25,8 @@ export async function GET(request: NextRequest) {
const date = requestUrl.searchParams.get('date');
const validatedData = zodInputModel.parse({ appId, date });
await isAuthorizedReadForApp(validatedData.appId);
const logsPath = PathUtils.appLogsFile(validatedData.appId, validatedData.date);
if (!await FsUtils.fileExists(logsPath)) {
throw new ServiceException(`Could not find logs for ${appId}.`);

View File

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

View File

@@ -1,24 +1,14 @@
'use server'
import { appVolumeEditZodModel } from "@/shared/model/volume-edit.model";
import { ServerActionResult, SuccessActionResult } from "@/shared/model/server-action-error-return.model";
import { SuccessActionResult } from "@/shared/model/server-action-error-return.model";
import appService from "@/server/services/app.service";
import { getAuthUserSession, saveFormAction, simpleAction } from "@/server/utils/action-wrapper.utils";
import { z } from "zod";
import { ServiceException } from "@/shared/model/service.exception.model";
import pvcService from "@/server/services/pvc.service";
import { fileMountEditZodModel } from "@/shared/model/file-mount-edit.model";
import { VolumeBackupEditModel, volumeBackupEditZodModel } from "@/shared/model/backup-volume-edit.model";
import volumeBackupService from "@/server/services/volume-backup.service";
import backupService from "@/server/services/standalone-services/backup.service";
import { volumeUploadZodModel } from "@/shared/model/volume-upload.model";
import restoreService from "@/server/services/restore.service";
import { isAuthorizedWriteForApp, saveFormAction, simpleAction } from "@/server/utils/action-wrapper.utils";
import { BasicAuthEditModel, basicAuthEditZodModel } from "@/shared/model/basic-auth-edit.model";
export const saveBasicAuth = async (prevState: any, inputData: BasicAuthEditModel) =>
saveFormAction(inputData, basicAuthEditZodModel, async (validatedData) => {
await getAuthUserSession();
await isAuthorizedWriteForApp(validatedData.appId);
await appService.saveBasicAuth({
...validatedData,
@@ -30,7 +20,7 @@ export const saveBasicAuth = async (prevState: any, inputData: BasicAuthEditMode
export const deleteBasicAuth = async (basicAuthId: string) =>
simpleAction(async () => {
await getAuthUserSession();
await isAuthorizedWriteForApp(await appService.getBasicAuthById(basicAuthId).then(b => b.appId));
await appService.deleteBasicAuthById(basicAuthId);
return new SuccessActionResult(undefined, 'Successfully deleted item');
});

View File

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

View File

@@ -7,24 +7,29 @@ import { AppExtendedModel } from "@/shared/model/app-extended.model";
import { Toast } from "@/frontend/utils/toast.utils";
import AppStatus from "./app-status";
import { ExternalLink, Hammer, Pause, Play, Rocket } from "lucide-react";
import { toast } from "sonner";
import { AppEventsDialog } from "./app-events-dialog";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import { UserSession } from "@/shared/model/sim-session.model";
import { 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';

View File

@@ -21,29 +21,31 @@ import { VolumeBackupExtendedModel } from "@/shared/model/volume-backup-extended
import BasicAuth from "./advanced/basic-auth";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import DbToolsCard from "./credentials/db-tools";
import { RolePermissionEnum } from "@/shared/model/role-extended.model.ts";
export default function AppTabs({
app,
role,
tabName,
s3Targets,
volumeBackups
}: {
app: AppExtendedModel;
role: RolePermissionEnum;
tabName: string;
s3Targets: S3Target[],
volumeBackups: VolumeBackupExtendedModel[]
}) {
const router = useRouter();
const readonly = role !== RolePermissionEnum.READWRITE;
const openTab = (tabName: string) => {
router.push(`/project/app/${app.id}?tabName=${tabName}`);
}
return (
<Tabs defaultValue="general" value={tabName} onValueChange={(newTab) => openTab(newTab)} className="space-y-4">
<ScrollArea >
<ScrollArea>
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
{app.appType !== 'APP' && <TabsTrigger value="credentials">Credentials</TabsTrigger>}
<TabsTrigger value="general">General</TabsTrigger>
@@ -56,35 +58,36 @@ export default function AppTabs({
</ScrollArea>
<TabsContent value="overview" className="grid grid-cols-1 3xl:grid-cols-2 gap-4">
<MonitoringTab app={app} />
<Logs app={app} />
<BuildsTab app={app} />
<WebhookDeploymentInfo app={app} />
<Logs role={role} app={app} />
<BuildsTab role={role} app={app} />
<WebhookDeploymentInfo role={role} app={app} />
</TabsContent>
{app.appType !== 'APP' && <TabsContent value="credentials" className="space-y-4">
<DbToolsCard app={app} />
{role === RolePermissionEnum.READWRITE && <DbToolsCard app={app} />}
<DbCredentials app={app} />
</TabsContent>}
<TabsContent value="general" className="space-y-4">
<GeneralAppSource app={app} />
<GeneralAppRateLimits app={app} />
<GeneralAppSource readonly={readonly} app={app} />
<GeneralAppRateLimits readonly={readonly} app={app} />
</TabsContent>
<TabsContent value="environment" className="space-y-4">
<EnvEdit app={app} />
<EnvEdit readonly={readonly} app={app} />
</TabsContent>
<TabsContent value="domains" className="space-y-4">
<DomainsList app={app} />
<InternalHostnames app={app} />
<DomainsList readonly={readonly} app={app} />
<InternalHostnames readonly={readonly} app={app} />
</TabsContent>
<TabsContent value="storage" className="space-y-4">
<StorageList app={app} />
<FileMount app={app} />
<StorageList readonly={readonly} app={app} />
<FileMount readonly={readonly} app={app} />
<VolumeBackupList
readonly={readonly}
app={app}
s3Targets={s3Targets}
volumeBackups={volumeBackups} />
</TabsContent>
<TabsContent value="advanced" className="space-y-4">
<BasicAuth app={app} />
<BasicAuth readonly={readonly} app={app} />
</TabsContent>
</Tabs>
)

View File

@@ -4,7 +4,7 @@ import appService from "@/server/services/app.service";
import dbGateService from "@/server/services/db-tool-services/dbgate.service";
import pgAdminService from "@/server/services/db-tool-services/pgadmin.service";
import phpMyAdminService from "@/server/services/db-tool-services/phpmyadmin.service";
import { getAuthUserSession, simpleAction } from "@/server/utils/action-wrapper.utils";
import { isAuthorizedReadForApp, isAuthorizedWriteForApp, simpleAction } from "@/server/utils/action-wrapper.utils";
import { AppTemplateUtils } from "@/server/utils/app-template.utils";
import { DatabaseTemplateInfoModel } from "@/shared/model/database-template-info.model";
import { ServerActionResult, SuccessActionResult } from "@/shared/model/server-action-error-return.model";
@@ -20,7 +20,7 @@ const dbToolClasses = new Map([
export const getDatabaseCredentials = async (appId: string) =>
simpleAction(async () => {
await getAuthUserSession();
await isAuthorizedReadForApp(appId);
const app = await appService.getExtendedById(appId);
const credentials = AppTemplateUtils.getDatabaseModelFromApp(app);
return new SuccessActionResult(credentials);
@@ -28,7 +28,7 @@ export const getDatabaseCredentials = async (appId: string) =>
export const getIsDbToolActive = async (appId: string, dbTool: DbToolIds) =>
simpleAction(async () => {
await getAuthUserSession();
await isAuthorizedWriteForApp(appId);
if (!dbToolClasses.has(dbTool)) {
throw new ServiceException('Unknown db tool');
}
@@ -38,7 +38,7 @@ export const getIsDbToolActive = async (appId: string, dbTool: DbToolIds) =>
export const deployDbTool = async (appId: string, dbTool: DbToolIds) =>
simpleAction(async () => {
await getAuthUserSession();
await isAuthorizedWriteForApp(appId);
const currentDbTool = dbToolClasses.get(dbTool);
if (!currentDbTool) {
@@ -51,7 +51,7 @@ export const deployDbTool = async (appId: string, dbTool: DbToolIds) =>
export const getLoginCredentialsForRunningDbTool = async (appId: string, dbTool: DbToolIds) =>
simpleAction(async () => {
await getAuthUserSession();
await isAuthorizedWriteForApp(appId);
const currentDbTool = dbToolClasses.get(dbTool);
if (!currentDbTool) {
@@ -63,7 +63,7 @@ export const getLoginCredentialsForRunningDbTool = async (appId: string, dbTool:
export const deleteDbToolDeploymentForAppIfExists = async (appId: string, dbTool: DbToolIds) =>
simpleAction(async () => {
await getAuthUserSession();
await isAuthorizedWriteForApp(appId);
const currentDbTool = dbToolClasses.get(dbTool);
if (!currentDbTool) {
@@ -76,7 +76,7 @@ export const deleteDbToolDeploymentForAppIfExists = async (appId: string, dbTool
export const downloadDbGateFilesForApp = async (appId: string) =>
simpleAction(async () => {
await getAuthUserSession();
await isAuthorizedWriteForApp(appId);
const url = await dbGateService.downloadDbGateFilesForApp(appId);
return new SuccessActionResult(url);
}) as Promise<ServerActionResult<unknown, string>>;

View File

@@ -4,7 +4,7 @@ import { AppPortModel, appPortZodModel } from "@/shared/model/default-port.model
import { appDomainEditZodModel } from "@/shared/model/domain-edit.model";
import { SuccessActionResult } from "@/shared/model/server-action-error-return.model";
import appService from "@/server/services/app.service";
import { getAuthUserSession, saveFormAction, simpleAction } from "@/server/utils/action-wrapper.utils";
import { getAuthUserSession, isAuthorizedWriteForApp, saveFormAction, simpleAction } from "@/server/utils/action-wrapper.utils";
import { z } from "zod";
import { TraefikMeUtils } from "@/shared/utils/traefik-me.utils";
import { ServiceException } from "@/shared/model/service.exception.model";
@@ -16,7 +16,7 @@ const actionAppDomainEditZodModel = appDomainEditZodModel.merge(z.object({
export const saveDomain = async (prevState: any, inputData: z.infer<typeof actionAppDomainEditZodModel>) =>
saveFormAction(inputData, actionAppDomainEditZodModel, async (validatedData) => {
await getAuthUserSession();
await isAuthorizedWriteForApp(validatedData.appId);
if (validatedData.hostname.includes('://')) {
const url = new URL(validatedData.hostname);
@@ -37,14 +37,14 @@ export const saveDomain = async (prevState: any, inputData: z.infer<typeof actio
export const deleteDomain = async (domainId: string) =>
simpleAction(async () => {
await getAuthUserSession();
await isAuthorizedWriteForApp(await appService.getDomainById(domainId).then(d => d.appId));
await appService.deleteDomainById(domainId);
return new SuccessActionResult(undefined, 'Successfully deleted domain');
});
export const savePort = async (prevState: any, inputData: AppPortModel, appId: string, portId?: string) =>
saveFormAction(inputData, appPortZodModel, async (validatedData) => {
await getAuthUserSession();
await isAuthorizedWriteForApp(appId);
await appService.savePort({
...validatedData,
id: portId ?? undefined,
@@ -54,7 +54,7 @@ export const savePort = async (prevState: any, inputData: AppPortModel, appId: s
export const deletePort = async (portId: string) =>
simpleAction(async () => {
await getAuthUserSession();
await isAuthorizedWriteForApp(await appService.getPortById(portId).then(p => p.appId));
await appService.deletePortById(portId);
return new SuccessActionResult(undefined, 'Successfully deleted port');
});

View File

@@ -13,8 +13,9 @@ import { OpenInNewWindowIcon } from "@radix-ui/react-icons";
import { useConfirmDialog } from "@/frontend/states/zustand.states";
export default function DomainsList({ app }: {
app: AppExtendedModel
export default function DomainsList({ app, readonly }: {
app: AppExtendedModel;
readonly: boolean;
}) {
const { openConfirmDialog: openDialog } = useConfirmDialog();
@@ -61,24 +62,24 @@ export default function DomainsList({ app }: {
<TableCell className="font-medium">{domain.port}</TableCell>
<TableCell className="font-medium">{domain.useSsl ? <CheckIcon /> : <XIcon />}</TableCell>
<TableCell className="font-medium">{domain.useSsl && domain.redirectHttps ? <CheckIcon /> : <XIcon />}</TableCell>
<TableCell className="font-medium flex gap-2">
{!readonly && <TableCell className="font-medium flex gap-2">
<DialogEditDialog appId={app.id} domain={domain}>
<Button variant="ghost"><EditIcon /></Button>
</DialogEditDialog>
<Button variant="ghost" onClick={() => asyncDeleteDomain(domain.id)}>
<TrashIcon />
</Button>
</TableCell>
</TableCell>}
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
<CardFooter>
{!readonly && <CardFooter>
<DialogEditDialog appId={app.id}>
<Button><Plus /> Add Domain</Button>
</DialogEditDialog>
</CardFooter>
</CardFooter>}
</Card >
</>;

View File

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

View File

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

View File

@@ -16,12 +16,14 @@ import { Textarea } from "@/components/ui/textarea";
import { AppExtendedModel } from "@/shared/model/app-extended.model";
export default function EnvEdit({ app }: {
app: AppExtendedModel
export default function EnvEdit({ app, readonly }: {
app: AppExtendedModel;
readonly: boolean;
}) {
const form = useForm<AppEnvVariablesModel>({
resolver: zodResolver(appEnvVariablesZodModel),
defaultValues: app
defaultValues: app,
disabled: readonly,
});
const [state, formAction] = useFormState((state: ServerActionResult<any, any>, payload: AppEnvVariablesModel) => saveEnvVariables(state, payload, app.id), FormUtils.getInitialFormState<typeof appEnvVariablesZodModel>());
@@ -63,9 +65,9 @@ export default function EnvEdit({ app }: {
)}
/>
</CardContent>
<CardFooter>
{!readonly && <CardFooter>
<SubmitButton>Save</SubmitButton>
</CardFooter>
</CardFooter>}
</form>
</Form >
</Card >

View File

@@ -7,13 +7,13 @@ import { ErrorActionResult, ServerActionResult, SuccessActionResult } from "@/sh
import { ServiceException } from "@/shared/model/service.exception.model";
import appService from "@/server/services/app.service";
import userService from "@/server/services/user.service";
import { getAuthUserSession, saveFormAction, simpleAction } from "@/server/utils/action-wrapper.utils";
import { getAuthUserSession, isAuthorizedWriteForApp, saveFormAction, simpleAction } from "@/server/utils/action-wrapper.utils";
export const saveGeneralAppSourceInfo = async (prevState: any, inputData: AppSourceInfoInputModel, appId: string) => {
if (inputData.sourceType === 'GIT') {
return saveFormAction(inputData, appSourceInfoGitZodModel, async (validatedData) => {
await getAuthUserSession();
await isAuthorizedWriteForApp(appId);
const existingApp = await appService.getById(appId);
await appService.save({
...existingApp,
@@ -24,7 +24,7 @@ export const saveGeneralAppSourceInfo = async (prevState: any, inputData: AppSou
});
} else if (inputData.sourceType === 'CONTAINER') {
return saveFormAction(inputData, appSourceInfoContainerZodModel, async (validatedData) => {
await getAuthUserSession();
await isAuthorizedWriteForApp(appId);
const existingApp = await appService.getById(appId);
await appService.save({
...existingApp,
@@ -43,7 +43,7 @@ export const saveGeneralAppRateLimits = async (prevState: any, inputData: AppRat
if (validatedData.replicas < 1) {
throw new ServiceException('Replica Count must be at least 1');
}
await getAuthUserSession();
await isAuthorizedWriteForApp(appId);
const extendedApp = await appService.getExtendedById(appId);
if (extendedApp.appVolumes.some(v => v.accessMode === 'ReadWriteOnce') && validatedData.replicas > 1) {

View File

@@ -21,12 +21,14 @@ import { AppExtendedModel } from "@/shared/model/app-extended.model";
import { cn } from "@/frontend/utils/utils";
export default function GeneralAppRateLimits({ app }: {
app: AppExtendedModel
export default function GeneralAppRateLimits({ app, readonly }: {
app: AppExtendedModel;
readonly: boolean;
}) {
const form = useForm<AppRateLimitsModel>({
resolver: zodResolver(appRateLimitsZodModel),
defaultValues: app
defaultValues: app,
disabled: readonly
});
const [state, formAction] = useFormState((state: ServerActionResult<any, any>, payload: AppRateLimitsModel) => saveGeneralAppRateLimits(state, payload, app.id), FormUtils.getInitialFormState<typeof appRateLimitsZodModel>());
@@ -125,10 +127,10 @@ export default function GeneralAppRateLimits({ app }: {
/>
</div>
</CardContent>
<CardFooter className="gap-4">
{!readonly && <CardFooter className="gap-4">
<SubmitButton>Save</SubmitButton>
<p className="text-red-500">{state?.message}</p>
</CardFooter>
</CardFooter>}
</form>
</Form >
</Card >

View File

@@ -18,15 +18,17 @@ import { App } from "@prisma/client";
import { toast } from "sonner";
import { AppExtendedModel } from "@/shared/model/app-extended.model";
export default function GeneralAppSource({ app }: {
app: AppExtendedModel
export default function GeneralAppSource({ app, readonly }: {
app: AppExtendedModel;
readonly: boolean;
}) {
const form = useForm<AppSourceInfoInputModel>({
resolver: zodResolver(appSourceInfoInputZodModel),
defaultValues: {
...app,
sourceType: app.sourceType as 'GIT' | 'CONTAINER'
}
},
disabled: readonly,
});
const [state, formAction] = useFormState((state: ServerActionResult<any, any>, payload: AppSourceInfoInputModel) => saveGeneralAppSourceInfo(state, payload, app.id), FormUtils.getInitialFormState<typeof appSourceInfoInputZodModel>());
@@ -197,10 +199,10 @@ export default function GeneralAppSource({ app }: {
</TabsContent>
</Tabs>
</CardContent>
<CardFooter className="gap-4">
{!readonly && <CardFooter className="gap-4">
<SubmitButton>Save</SubmitButton>
<p className="text-red-500">{state?.message}</p>
</CardFooter>
</CardFooter>}
</form>
</Form >
</Card >

View File

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

View File

@@ -8,55 +8,54 @@ import buildService from "@/server/services/build.service";
import deploymentService from "@/server/services/deployment.service";
import monitoringService from "@/server/services/monitoring.service";
import podService from "@/server/services/pod.service";
import { getAuthUserSession, simpleAction } from "@/server/utils/action-wrapper.utils";
import { isAuthorizedReadForApp, isAuthorizedWriteForApp, simpleAction } from "@/server/utils/action-wrapper.utils";
import { PodsResourceInfoModel } from "@/shared/model/pods-resource-info.model";
import appLogsService from "@/server/services/standalone-services/app-logs.service";
import { DownloadableAppLogsModel } from "@/shared/model/downloadable-app-logs.model";
import { ServiceException } from "@/shared/model/service.exception.model";
export const getDeploymentsAndBuildsForApp = async (appId: string) =>
simpleAction(async () => {
await getAuthUserSession();
await isAuthorizedReadForApp(appId);
const app = await appService.getExtendedById(appId);
return await deploymentService.getDeploymentHistory(app.projectId, appId);
}) as Promise<ServerActionResult<unknown, DeploymentInfoModel[]>>;
export const deleteBuild = async (buildName: string) =>
simpleAction(async () => {
await getAuthUserSession();
await isAuthorizedWriteForApp(await buildService.getAppIdByBuildName(buildName));
await buildService.deleteBuild(buildName);
return new SuccessActionResult(undefined, 'Successfully stopped and deleted build.');
}) as Promise<ServerActionResult<unknown, void>>;
export const getPodsForApp = async (appId: string) =>
simpleAction(async () => {
await getAuthUserSession();
await isAuthorizedReadForApp(appId);
const app = await appService.getExtendedById(appId);
return await podService.getPodsForApp(app.projectId, appId);
}) as Promise<ServerActionResult<unknown, PodsInfoModel[]>>;
export const getRessourceDataApp = async (projectId: string, appId: string) =>
simpleAction(async () => {
await getAuthUserSession();
await isAuthorizedReadForApp(appId);
return await monitoringService.getMonitoringForApp(projectId, appId);
}) as Promise<ServerActionResult<unknown, PodsResourceInfoModel>>;
export const createNewWebhookUrl = async (appId: string) =>
simpleAction(async () => {
await getAuthUserSession();
await isAuthorizedWriteForApp(appId);
await appService.regenerateWebhookId(appId);
});
export const getDownloadableLogs = async (appId: string) =>
simpleAction(async () => {
await getAuthUserSession();
await isAuthorizedReadForApp(appId);
return new SuccessActionResult(await appLogsService.getAvailableLogsForApp(appId));
}) as Promise<ServerActionResult<unknown, DownloadableAppLogsModel[]>>;
export const exportLogsToFileForToday = async (appId: string) =>
simpleAction(async () => {
await getAuthUserSession();
await isAuthorizedReadForApp(appId);
const result = await appLogsService.writeAppLogsToDiskForApp(appId);
if (!result) {
throw new ServiceException('There are no logs available for today.');

View File

@@ -12,11 +12,14 @@ import { DeploymentInfoModel } from "@/shared/model/deployment-info.model";
import DeploymentStatusBadge from "./deployment-status-badge";
import { BuildLogsDialog } from "./build-logs-overlay";
import ShortCommitHash from "@/components/custom/short-commit-hash";
import { RolePermissionEnum } from "@/shared/model/role-extended.model.ts";
export default function BuildsTab({
app
app,
role
}: {
app: AppExtendedModel;
role: RolePermissionEnum;
}) {
const { openConfirmDialog: openDialog } = useConfirmDialog();
@@ -89,7 +92,7 @@ export default function BuildsTab({
<div className="flex gap-4">
<div className="flex-1"></div>
{item.deploymentId && <Button variant="secondary" onClick={() => setSelectedDeploymentForLogs(item)}>Show Logs</Button>}
{item.buildJobName && item.status === 'BUILDING' && <Button variant="destructive" onClick={() => deleteBuildClick(item.buildJobName!)}>Stop Build</Button>}
{role === RolePermissionEnum.READWRITE && item.buildJobName && item.status === 'BUILDING' && <Button variant="destructive" onClick={() => deleteBuildClick(item.buildJobName!)}>Stop Build</Button>}
</div>
</>
}}

View File

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

View File

@@ -7,11 +7,14 @@ import { useConfirmDialog } from "@/frontend/states/zustand.states";
import { Toast } from "@/frontend/utils/toast.utils";
import { ClipboardCopy } from "lucide-react";
import { toast } from "sonner";
import { RolePermissionEnum } from "@/shared/model/role-extended.model.ts";
export default function WebhookDeploymentInfo({
app
app,
role
}: {
app: AppExtendedModel;
role: RolePermissionEnum;
}) {
const { openConfirmDialog } = useConfirmDialog();
const [webhookUrl, setWebhookUrl] = useState<string | undefined>(undefined);
@@ -52,7 +55,7 @@ export default function WebhookDeploymentInfo({
{webhookUrl && <Button className="flex-1 truncate" variant="secondary" onClick={copyWebhookUrl}>
<span className="truncate">{webhookUrl}</span> <ClipboardCopy />
</Button>}
<Button onClick={createNewWebhookUrlAsync} variant={webhookUrl ? 'ghost' : 'secondary'}>{webhookUrl ? 'Generate new Webhook URL' : 'Enable Webhook deployments'}</Button>
{role === RolePermissionEnum.READWRITE && <Button onClick={createNewWebhookUrlAsync} variant={webhookUrl ? 'ghost' : 'secondary'}>{webhookUrl ? 'Generate new Webhook URL' : 'Enable Webhook deployments'}</Button>}
</div>
</CardContent>
</Card>

View File

@@ -1,9 +1,10 @@
import { getAuthUserSession } from "@/server/utils/action-wrapper.utils";
import { getAuthUserSession, isAuthorizedReadForApp } from "@/server/utils/action-wrapper.utils";
import appService from "@/server/services/app.service";
import AppTabs from "./app-tabs";
import AppBreadcrumbs from "./app-breadcrumbs";
import s3TargetService from "@/server/services/s3-target.service";
import volumeBackupService from "@/server/services/volume-backup.service";
import { 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}

View File

@@ -3,7 +3,7 @@
import { appVolumeEditZodModel } from "@/shared/model/volume-edit.model";
import { ServerActionResult, SuccessActionResult } from "@/shared/model/server-action-error-return.model";
import appService from "@/server/services/app.service";
import { getAuthUserSession, saveFormAction, simpleAction } from "@/server/utils/action-wrapper.utils";
import { getAuthUserSession, isAuthorizedReadForApp, isAuthorizedWriteForApp, saveFormAction, simpleAction } from "@/server/utils/action-wrapper.utils";
import { z } from "zod";
import { ServiceException } from "@/shared/model/service.exception.model";
import pvcService from "@/server/services/pvc.service";
@@ -15,6 +15,7 @@ import { volumeUploadZodModel } from "@/shared/model/volume-upload.model";
import restoreService from "@/server/services/restore.service";
import fileBrowserService from "@/server/services/file-browser-service";
import monitoringService from "@/server/services/monitoring.service";
import dataAccess from "@/server/adapter/db.client";
const actionAppVolumeEditZodModel = appVolumeEditZodModel.merge(z.object({
appId: z.string(),
@@ -23,7 +24,7 @@ const actionAppVolumeEditZodModel = appVolumeEditZodModel.merge(z.object({
export const restoreVolumeFromZip = async (prevState: any, inputData: FormData, volumeId: string) =>
simpleAction(async () => {
await getAuthUserSession();
await validateVolumeWriteAuthorization(volumeId);
const validatedData = volumeUploadZodModel.parse({
volumeId,
file: ''
@@ -39,7 +40,7 @@ export const restoreVolumeFromZip = async (prevState: any, inputData: FormData,
export const saveVolume = async (prevState: any, inputData: z.infer<typeof actionAppVolumeEditZodModel>) =>
saveFormAction(inputData, actionAppVolumeEditZodModel, async (validatedData) => {
await getAuthUserSession();
await isAuthorizedWriteForApp(validatedData.appId);
const existingApp = await appService.getExtendedById(validatedData.appId);
const existingVolume = validatedData.id ? await appService.getVolumeById(validatedData.id) : undefined;
if (existingVolume && existingVolume.size > validatedData.size) {
@@ -57,20 +58,20 @@ export const saveVolume = async (prevState: any, inputData: z.infer<typeof actio
export const deleteVolume = async (volumeId: string) =>
simpleAction(async () => {
await getAuthUserSession();
await validateVolumeWriteAuthorization(volumeId);
await appService.deleteVolumeById(volumeId);
return new SuccessActionResult(undefined, 'Successfully deleted volume');
});
export const getPvcUsage = async (appId: string, projectId: string) =>
simpleAction(async () => {
await getAuthUserSession();
await isAuthorizedReadForApp(appId);
return monitoringService.getPvcUsageFromApp(appId, projectId);
}) as Promise<ServerActionResult<any, { pvcName: string, usedBytes: number }[]>>;
export const downloadPvcData = async (volumeId: string) =>
simpleAction(async () => {
await getAuthUserSession();
await validateVolumeReadAuthorization(volumeId);
const fileNameOfDownloadedFile = await pvcService.downloadPvcData(volumeId);
return new SuccessActionResult(fileNameOfDownloadedFile, 'Successfully zipped volume data'); // returns the download path on the server
}) as Promise<ServerActionResult<any, string>>;
@@ -82,7 +83,7 @@ const actionAppFileMountEditZodModel = fileMountEditZodModel.merge(z.object({
export const saveFileMount = async (prevState: any, inputData: z.infer<typeof actionAppFileMountEditZodModel>) =>
saveFormAction(inputData, actionAppFileMountEditZodModel, async (validatedData) => {
await getAuthUserSession();
await isAuthorizedWriteForApp(validatedData.appId);
await appService.saveFileMount({
...validatedData,
id: validatedData.id ?? undefined,
@@ -91,14 +92,14 @@ export const saveFileMount = async (prevState: any, inputData: z.infer<typeof ac
export const deleteFileMount = async (fileMountId: string) =>
simpleAction(async () => {
await getAuthUserSession();
await validateFileMountWriteAuthorization(fileMountId);
await appService.deleteFileMountById(fileMountId);
return new SuccessActionResult(undefined, 'Successfully deleted volume');
});
export const saveBackupVolume = async (prevState: any, inputData: VolumeBackupEditModel) =>
saveFormAction(inputData, volumeBackupEditZodModel, async (validatedData) => {
await getAuthUserSession();
await validateVolumeWriteAuthorization(validatedData.volumeId);
if (validatedData.retention < 1) {
throw new ServiceException('Retention must be at least 1');
}
@@ -112,7 +113,7 @@ export const saveBackupVolume = async (prevState: any, inputData: VolumeBackupEd
export const deleteBackupVolume = async (backupVolumeId: string) =>
simpleAction(async () => {
await getAuthUserSession();
await validateBackupVolumeWriteAuthorization(backupVolumeId);
await volumeBackupService.deleteById(backupVolumeId);
await backupService.registerAllBackups();
return new SuccessActionResult(undefined, 'Successfully deleted backup schedule');
@@ -120,17 +121,69 @@ export const deleteBackupVolume = async (backupVolumeId: string) =>
export const runBackupVolumeSchedule = async (backupVolumeId: string) =>
simpleAction(async () => {
await getAuthUserSession();
await validateBackupVolumeWriteAuthorization(backupVolumeId);
await backupService.runBackupForVolume(backupVolumeId);
return new SuccessActionResult(undefined, 'Backup created and uploaded successfully');
});
export const openFileBrowserForVolume = async (volumeId: string) =>
simpleAction(async () => {
await getAuthUserSession();
await validateVolumeWriteAuthorization(volumeId);
const fileBrowserDomain = await fileBrowserService.deployFileBrowserForVolume(volumeId);
return new SuccessActionResult(fileBrowserDomain, 'File browser started successfully');
}) as Promise<ServerActionResult<any, {
url: string;
password: string;
}>>;
}>>;
async function validateVolumeWriteAuthorization(volumeId: string) {
const volumeAppId = await dataAccess.client.appVolume.findFirstOrThrow({
where: {
id: volumeId,
},
select: {
appId: true,
}
});
await isAuthorizedWriteForApp(volumeAppId?.appId);
}
async function validateVolumeReadAuthorization(volumeId: string) {
const volumeAppId = await dataAccess.client.appVolume.findFirstOrThrow({
where: {
id: volumeId,
},
select: {
appId: true,
}
});
await isAuthorizedReadForApp(volumeAppId?.appId);
}
async function validateFileMountWriteAuthorization(fileMountId: string) {
const fileMountAppId = await dataAccess.client.appFileMount.findFirstOrThrow({
where: {
id: fileMountId,
},
select: {
appId: true,
}
});
await isAuthorizedWriteForApp(fileMountAppId?.appId);
}
async function validateBackupVolumeWriteAuthorization(backupVolumeId: string) {
const volumeAppId = await dataAccess.client.volumeBackup.findFirstOrThrow({
where: {
id: backupVolumeId,
},
select: {
volume: {
select: {
appId: true,
}
}
}
});
await isAuthorizedWriteForApp(volumeAppId?.volume.appId);
}

View File

@@ -14,8 +14,9 @@ import FileMountEditDialog from "./file-mount-edit-dialog";
type AppVolumeWithCapacity = (AppVolume & { capacity?: string });
export default function FileMount({ app }: {
app: AppExtendedModel
export default function FileMount({ app, readonly }: {
app: AppExtendedModel;
readonly: boolean;
}) {
const { openConfirmDialog: openDialog } = useConfirmDialog();
@@ -50,24 +51,25 @@ export default function FileMount({ app }: {
{app.appFileMounts.map(fileMount => (
<TableRow key={fileMount.containerMountPath}>
<TableCell className="font-medium">{fileMount.containerMountPath}</TableCell>
<TableCell className="font-medium flex gap-2">
{!readonly && <TableCell className="font-medium flex gap-2">
<FileMountEditDialog app={app} fileMount={fileMount}>
<Button variant="ghost"><EditIcon /></Button>
</FileMountEditDialog>
<Button variant="ghost" onClick={() => asyncDeleteFileMount(fileMount.id)}>
<TrashIcon />
</Button>
</TableCell>
</TableCell>}
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
<CardFooter>
{!readonly && <CardFooter>
<FileMountEditDialog app={app}>
<Button>Add File Mount</Button>
</FileMountEditDialog>
</CardFooter>
}
</Card >
</>;
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -191,6 +191,23 @@ class BuildService {
}
}
async getBuildByName(buildName: string) {
const jobs = await k3s.batch.listNamespacedJob(BUILD_NAMESPACE);
return jobs.body.items.find((job) => job.metadata?.name === buildName);
}
async getAppIdByBuildName(buildName: string) {
const job = await this.getBuildByName(buildName);
if (!job) {
throw new ServiceException(`No build found with name ${buildName}`);
}
const appId = job.metadata?.annotations?.[Constants.QS_ANNOTATION_APP_ID];
if (!appId) {
throw new ServiceException(`No appId found for build ${buildName}`);
}
return appId;
}
async deleteBuild(buildName: string) {
await k3s.batch.deleteNamespacedJob(buildName, BUILD_NAMESPACE);
console.log(`Deleted build job ${buildName}`);

View File

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