This is an automated email from the ASF dual-hosted git repository. mgubaidullin pushed a commit to branch main in repository https://gitbox.apache.org/repos/asf/camel-karavan.git
commit 45c2a0ad6cbabe18ec0f338cacc71fe9dbb91feb Author: Marat Gubaidullin <[email protected]> AuthorDate: Mon Aug 24 14:39:41 2026 -0400 Karavan Webapp UI API --- karavan-app/src/main/webui/src/api/AccessApi.tsx | 174 +++++ .../src/main/webui/src/api/ComplexityApi.tsx | 41 ++ .../src/main/webui/src/api/DiagnosticsApi.tsx | 22 + karavan-app/src/main/webui/src/api/KaravanApi.tsx | 713 +++++++++++++++++++++ .../src/main/webui/src/api/KubernetesApi.ts | 20 + karavan-app/src/main/webui/src/api/LogWatchApi.tsx | 77 +++ .../src/main/webui/src/api/NotificationApi.tsx | 105 +++ karavan-app/src/main/webui/src/api/SearchApi.tsx | 26 + karavan-app/src/main/webui/src/api/SystemApi.tsx | 194 ++++++ .../src/main/webui/src/api/auth/AuthApi.tsx | 253 ++++++++ .../src/main/webui/src/api/auth/AuthFetch.ts | 53 ++ .../src/main/webui/src/api/auth/AuthProvider.tsx | 103 +++ karavan-app/src/main/webui/src/api/auth/SsoApi.tsx | 157 +++++ 13 files changed, 1938 insertions(+) diff --git a/karavan-app/src/main/webui/src/api/AccessApi.tsx b/karavan-app/src/main/webui/src/api/AccessApi.tsx new file mode 100644 index 00000000..e04e6cb3 --- /dev/null +++ b/karavan-app/src/main/webui/src/api/AccessApi.tsx @@ -0,0 +1,174 @@ +import {AccessPassword, AccessRole, AccessToken, AccessUser, GenerateTokenRequest, GenerateTokenResponse, SessionInfo} from "@models/AccessModels"; +import {ErrorEventBus} from "@bus/ErrorEventBus"; +import {AuthApi} from "./auth/AuthApi"; +import {AxiosResponse} from "axios"; + +const instance = AuthApi.getInstance(); + +export class AccessApi { + + static async getUser(username: string): Promise<AccessUser | null> { + try { + const res = await instance.get(`/ui/access/users/${username}`); + return res.status === 200 ? res.data : null; + } catch (err) { + ErrorEventBus.sendApiError(err); + return null; + } + } + + static async putUser(user: AccessUser): Promise<[boolean, AxiosResponse | any]> { + try { + const res = await instance.put('/ui/access/users', user); + return [res.status === 200, user]; + } catch (err) { + ErrorEventBus.sendApiError(err); + return [false, err]; + } + } + + static async getUsers(): Promise<AccessUser[]> { + try { + const res = await instance.get('/ui/access/users'); + return res.status === 200 ? res.data : []; + } catch (err) { + ErrorEventBus.sendApiError(err); + return []; + } + } + + static async getRoles(): Promise<AccessRole[]> { + try { + const res = await instance.get('/ui/access/roles'); + return res.status === 200 ? res.data : []; + } catch (err) { + ErrorEventBus.sendApiError(err); + return []; + } + } + + static async getSessions(): Promise<SessionInfo[]> { + try { + const res = await instance.get('/ui/access/sessions'); + return res.status === 200 ? res.data : []; + } catch (err) { + ErrorEventBus.sendApiError(err); + return []; + } + } + + static async getTokens(): Promise<AccessToken[]> { + try { + const res = await instance.get('/ui/access/tokens'); + return res.status === 200 ? res.data : []; + } catch (err) { + ErrorEventBus.sendApiError(err); + return []; + } + } + + static async postUser(user: AccessUser): Promise<[boolean, AxiosResponse | any]> { + try { + const res = await instance.post('/ui/access/users', user); + return [res.status === 200 || res.status === 201, user]; + } catch (err) { + ErrorEventBus.sendApiError(err); + return [false, err]; + } + } + + static async deleteUser(username: string): Promise<boolean> { + try { + const res = await instance.delete(`/ui/access/users/${username}`); + return res.status === 202; + } catch (err) { + ErrorEventBus.sendApiError(err); + return false; + } + } + + static async deleteRole(rolename: string): Promise<boolean> { + try { + const res = await instance.delete(`/ui/access/roles/${rolename}`); + return res.status === 202; + } catch (err) { + ErrorEventBus.sendApiError(err); + return false; + } + } + + static async deleteSession(username: string): Promise<boolean> { + try { + const res = await instance.delete(`/ui/access/sessions/${username}`); + return res.status === 202; + } catch (err) { + ErrorEventBus.sendApiError(err); + return false; + } + } + + static async deleteToken(hashedToken: string): Promise<boolean> { + try { + const res = await instance.delete(`/ui/access/tokens/${hashedToken}`); + return res.status === 202; + } catch (err) { + ErrorEventBus.sendApiError(err); + return false; + } + } + + static async setUserStatus(user: AccessUser, status: string): Promise<AccessUser | null> { + try { + const res = await instance.put(`/ui/access/users/${status}`, user); + return res.status === 200 ? res.data : null; + } catch (err) { + ErrorEventBus.sendApiError(err); + return null; + } + } + + static async setUserRole( + user: AccessUser, + role: string | undefined, + command: "activate" | "inactivate" | "add" | "remove" + ): Promise<AccessUser | null> { + try { + const res = await instance.put('/ui/access/userRole', { username: user.username, role, command }); + return res.status === 200 ? res.data : null; + } catch (err) { + ErrorEventBus.sendApiError(err); + return null; + } + } + + static async postRole(role: AccessRole): Promise<[boolean, AxiosResponse | any]> { + try { + const res = await instance.post('/ui/access/roles', role); + return [res.status === 200 || res.status === 201, role]; + } catch (err) { + ErrorEventBus.sendApiError(err); + return [false, err]; + } + } + + static async generateToken(request: GenerateTokenRequest): Promise<[boolean, GenerateTokenResponse | any]> { + try { + const res = await instance.post('/ui/access/tokens', request); + // On success, res.data contains the GenerateTokenResponse (rawToken + metadata) + return [res.status === 200 || res.status === 201, res.data]; + } catch (err) { + ErrorEventBus.sendApiError(err); + return [false, err]; + } + } + + static async setPassword(username: string, password: AccessPassword): Promise<[boolean, AxiosResponse | any]> { + try { + const res = await instance.post('/ui/access/password', { ...password, username }); + return [res.status === 200 || res.status === 201 || res.status === 204, res]; + } catch (err) { + ErrorEventBus.sendApiError(err); + return [false, err]; + } + } +} \ No newline at end of file diff --git a/karavan-app/src/main/webui/src/api/ComplexityApi.tsx b/karavan-app/src/main/webui/src/api/ComplexityApi.tsx new file mode 100644 index 00000000..1ae0ad92 --- /dev/null +++ b/karavan-app/src/main/webui/src/api/ComplexityApi.tsx @@ -0,0 +1,41 @@ +import axios from "axios"; +import {ErrorEventBus} from "@bus/ErrorEventBus"; +import {ComplexityProject} from "@models/ComplexityModels"; +import {AuthApi} from "./auth/AuthApi"; + +axios.defaults.headers.common['Accept'] = 'application/json'; +axios.defaults.headers.common['Content-Type'] = 'application/json'; +const instance = AuthApi.getInstance(); + +export class ComplexityApi { + + static async getComplexityProject(projectId: string, after: (complexity?: ComplexityProject) => void) { + instance.get('/ui/complexity/' + projectId) + .then(res => { + if (res.status === 200) { + after(res.data); + } else { + after(undefined); + } + }).catch(err => { + ErrorEventBus.sendApiError(err); + after(undefined); + }); + } + + static async getComplexityProjects(after: (complexities: ComplexityProject[]) => void) { + const x = Date.now(); + instance.get('/ui/complexity') + .then(res => { + if (res.status === 200) { + const c: ComplexityProject[] = Array.isArray(res.data) ? res.data?.map(x => new ComplexityProject(x)) : []; + after(c); + } else { + after([]); + } + }).catch(err => { + ErrorEventBus.sendApiError(err); + after([]); + }); + } +} diff --git a/karavan-app/src/main/webui/src/api/DiagnosticsApi.tsx b/karavan-app/src/main/webui/src/api/DiagnosticsApi.tsx new file mode 100644 index 00000000..79ef89fd --- /dev/null +++ b/karavan-app/src/main/webui/src/api/DiagnosticsApi.tsx @@ -0,0 +1,22 @@ +import axios from "axios"; +import {AuthApi} from "./auth/AuthApi"; +import {ErrorEventBus} from "@bus/ErrorEventBus"; +import {CamelStatus} from "@models/ProjectModels"; + +axios.defaults.headers.common['Accept'] = 'application/json'; +axios.defaults.headers.common['Content-Type'] = 'application/json'; +const instance = AuthApi.getInstance(); + +export class DiagnosticsApi { + + static async getAllCamelStatuses(after: (statuses: CamelStatus[]) => void) { + instance.get('/ui/status/camel') + .then(res => { + if (res.status === 200) { + after(res.data); + } + }).catch(err => { + ErrorEventBus.sendApiError(err); + }); + } +} diff --git a/karavan-app/src/main/webui/src/api/KaravanApi.tsx b/karavan-app/src/main/webui/src/api/KaravanApi.tsx new file mode 100644 index 00000000..f99d91c5 --- /dev/null +++ b/karavan-app/src/main/webui/src/api/KaravanApi.tsx @@ -0,0 +1,713 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import axios, {AxiosResponse} from "axios"; +import { + AppConfig, + CamelStatus, + CamelStatusName, + ContainerStatus, + DeploymentStatus, + Project, + ProjectCommited, + ProjectFile, + ProjectFileCommited, + ProjectType, + ServiceStatus +} from "@models/ProjectModels"; +import {Buffer} from 'buffer'; +import {EventBus} from "@designer/utils/EventBus"; +import {ErrorEventBus} from "@bus/ErrorEventBus"; +import {AuthApi, getCurrentUser} from "./auth/AuthApi"; +import {ProjectFolderCommit} from "@stores/CommitsStore"; + +const instance = AuthApi.getInstance(); + +export class KaravanApi { + + static async getReadiness(after: (readiness: any) => void): Promise<void> { + axios.get('/public/readiness', {headers: {'Accept': 'application/json'}}) + .then(res => { + if (res.status === 200) { + after(res.data); + } else { + after(undefined); + } + }).catch(err => { + ErrorEventBus.sendApiError(err); + }); + } + + static async getConfiguration(after: (config: AppConfig) => void) { + instance.get('/ui/configuration') + .then(res => { + if (res.status === 200) { + after(res.data); + } + }).catch(err => { + ErrorEventBus.sendApiError(err); + }); + } + + static async getInfrastructureInfo(after: (info: any) => void) { + instance.get('/ui/configuration/info') + .then(res => { + if (res.status === 200) { + after(res.data); + } + }).catch(err => { + ErrorEventBus.sendApiError(err); + }); + } + + static async getProject(projectId: string, after: (project: Project) => void) { + instance.get('/ui/project/' + projectId) + .then(res => { + if (res.status === 200) { + after(res.data); + } + }).catch(err => { + ErrorEventBus.sendApiError(err); + }); + } + + static async getAllCamelStatuses(name: CamelStatusName | null, after: (statuses: CamelStatus[]) => void) { + instance.get(`/ui/status/camel/${name || ''}`) + .then(res => { + if (res.status === 200) { + after(res.data); + } + }).catch(err => { + ErrorEventBus.sendApiError(err); + }); + } + + static async getProjects(after: (projects: Project[]) => void, type?: ProjectType.integration) { + instance.get('/ui/project' + (type !== undefined ? "?type=" + type : "")) + .then(res => { + if (res.status === 200) { + after(res.data.map((p: Partial<Project> | undefined) => new Project(p))); + } + }).catch(err => { + ErrorEventBus.sendApiError(err); + }); + } + static async getProjectsCommited(after: (projects: ProjectCommited[]) => void) { + instance.get('/ui/project/commited/all') + .then(res => { + if (res.status === 200) { + after(res.data); + } + }).catch(err => { + ErrorEventBus.sendApiError(err); + }); + } + + static async postProject(project: Project, after: (result: boolean, res: AxiosResponse<Project> | any) => void) { + try { + instance.post('/ui/project', project) + .then(res => { + if (res.status === 200) { + after(true, res); + } + }).catch(err => { + console.error(err); + after(false, err); + EventBus.sendAlert("Error", err?.message, "danger") + }); + } catch (error: any) { + console.error(error); + after(false, error); + EventBus.sendAlert("Error", error?.message, "danger") + } + } + + static copyProject(sourceProject: string, project: Project, after: (result: boolean, res: AxiosResponse<Project> | any) => void) { + try { + instance.post('/ui/project/copy/' + sourceProject, project) + .then(res => { + if (res.status === 200) { + after(true, res); + } + }).catch(err => { + after(false, err); + }); + } catch (error: any) { + after(false, error); + } + } + + static async deleteProject(project: Project, deleteContainers: boolean, after: (res: AxiosResponse<any>) => void) { + instance.delete('/ui/project/' + encodeURI(project.projectId) + (deleteContainers ? '?deleteContainers=true' : '')) + .then(res => { + after(res); + }).catch(err => { + after(err); + }); + } + + static async buildProject(project: Project, tag: string, after: (res: AxiosResponse<any>) => void) { + instance.post('/ui/project/build/' + tag, project) + .then(res => { + after(res); + }).catch(err => { + after(err); + }); + } + + static async getFiles(projectId: string, after: (files: ProjectFile[]) => void) { + instance.get(`/ui/file/${projectId}`) + .then(res => { + if (res.status === 200) { + after(res.data); + } + }).catch(err => { + ErrorEventBus.sendApiError(err); + }); + } + static async getCommitedFiles(projectId: string, after: (files: ProjectFileCommited[]) => void) { + instance.get(`/ui/file/commited/${projectId}`) + .then(res => { + if (res.status === 200) { + after(res.data); + } + }).catch(err => { + ErrorEventBus.sendApiError(err); + }); + } + + static async getProjectCommits(projectId: string, after: (commits: ProjectFolderCommit[]) => void) { + instance.get(`/ui/git/commits/${projectId}`) + .then(res => { + if (res.status === 200) { + after(res.data); + } + }).catch(err => { + ErrorEventBus.sendApiError(err); + }); + } + + static async loadProjectCommits(projectId: string, after: (res: any) => void) { + instance.post(`/ui/git/commits/${projectId}`) + .then(res => { + if (res.status === 202) { + after(res); + } + }).catch(err => { + ErrorEventBus.sendApiError(err); + }); + } + + static async getSystemCommits(after: (res: any) => void) { + instance.get(`/ui/git/system`) + .then(res => { + if (res.status === 200) { + after(res.data); + } + }).catch(err => { + ErrorEventBus.sendApiError(err); + }); + } + + static async getProjectFilesByName(projectId: string, filename: string, after: (files: ProjectFile) => void) { + instance.get(`/ui/file/${projectId}?filename=${filename}`) + .then(res => { + if (res.status === 200 && res.data !== undefined) { + after(res.data?.at(0)); + } + }).catch(err => { + ErrorEventBus.sendApiError(err); + }); + } + + static async getFilesByName(filename: string, after: (files: ProjectFile[]) => void) { + instance.get(`/ui/file?filename=${filename}`) + .then(res => { + if (res.status === 200) { + after(res.data); + } + }).catch(err => { + ErrorEventBus.sendApiError(err); + }); + } + + static async getFileCommited(projectId: string, filename: string, after: (file: ProjectFile) => void) { + instance.get('/ui/file/commited/' + projectId + '/' + filename) + .then(res => { + if (res.status === 200) { + after(res.data); + } + }).catch(err => { + ErrorEventBus.sendApiError(err); + }); + } + + static async getFilesDiff(projectId: string, after: (diff: any) => void) { + instance.get('/ui/file/diff/' + projectId) + .then(res => { + if (res.status === 200) { + after(res.data); + } + }).catch(err => { + ErrorEventBus.sendApiError(err); + }); + } + + static async saveProjectFile(file: ProjectFile, after: (result: boolean, file: ProjectFile | any) => void) { + try { + instance.post('/ui/file', file) + .then(res => { + if (res.status === 200) { + after(true, res.data); + } else { + after(false, res?.data); + } + }).catch(err => { + after(false, err); + }); + } catch (error: any) { + after(false, error); + } + } + + static async renameProjectFile(projectId: string, filename: string, newName: string, after: (result: boolean, err?: Error) => void) { + try { + instance.patch(`/ui/file/${projectId}/${filename}`, {newName: newName}) + .then(res => { + if (res.status === 200) { + after(true); + } else if (res.status === 409) { + after(false, {message: res?.data} as Error); + } else { + after(false); + } + }).catch(err => { + after(false, err); + }); + } catch (error: any) { + after(false, error); + } + } + + static async putProjectFile(file: ProjectFile, after: (res: AxiosResponse<any>) => void) { + instance.put('/ui/file', file) + .then(res => { + after(res); + }).catch(err => { + after(err); + }); + } + + static async deleteProjectFile(file: ProjectFile, after: (res: AxiosResponse<any>) => void) { + instance.delete('/ui/file/' + file.projectId + '/' + file.name) + .then(res => { + after(res); + }).catch(err => { + after(err); + }); + } + + static async deleteProjectFileByName(projectId: string, filename: string, after: (res: AxiosResponse<any>) => void) { + instance.delete(`/ui/file/${projectId}/${filename}`) + .then(res => { + after(res); + }).catch(err => { + after(err); + }); + } + + static async copyProjectFile(fromProjectId: string, fromFilename: string, toProjectId: string, toFilename: string, overwrite: boolean, after: (res: AxiosResponse<any>) => void) { + instance.post('/ui/file/copy', {fromProjectId: fromProjectId, fromFilename: fromFilename, toProjectId: toProjectId, toFilename: toFilename, overwrite: overwrite}) + .then(res => { + after(res); + }).catch(err => { + after(err); + }); + } + + static async push(params: {}, after: (res: AxiosResponse<any>) => void) { + instance.post('/ui/git', params) + .then(res => { + after(res); + }).catch(err => { + after(err); + }); + } + + static async pull(projectId: string | undefined, after: (res: AxiosResponse<any> | any) => void) { + instance.put(`/ui/git/${projectId ?? ''}`) + .then(res => { + after(res); + }).catch(err => { + after(err); + }); + } + + + static async reloadDevModeCode(projectId: string, after: (res: AxiosResponse<any>) => void) { + instance.get('/ui/devmode/reload/' + projectId) + .then(res => { + after(res); + }).catch(err => { + after(err); + }); + } + + static async getProjectCamelStatuses(projectId: string, after: (res: AxiosResponse<CamelStatus[]>) => void) { + instance.get(`/ui/project/status/camel/${projectId}` ) + .then(res => { + after(res); + }).catch(err => { + after(err); + }); + } + + static async getProjectCamelTraces(projectId: string, after: (res: AxiosResponse<CamelStatus[]>) => void) { + instance.get(`/ui/project/traces/${projectId}`) + .then(res => { + after(res); + }).catch(err => { + after(err); + }); + } + + static async startDevModeContainer(projectId: string, verbose: boolean, compile: boolean, after: (res: AxiosResponse<any>) => void) { + instance.get(`/ui/devmode/run/${projectId}/${verbose.toString()}/${compile.toString()}`) + .then(res => { + after(res); + }).catch(err => { + after(err); + }); + } + + static async deleteDevModeContainer(name: string, deletePVC: boolean, after: (res: AxiosResponse<any>) => void) { + instance.delete('/ui/devmode/' + name + "/" + deletePVC) + .then(res => { + after(res); + }).catch(err => { + after(err); + }); + } + + + + static async stopBuild(environment: string, buildName: string, after: (res: AxiosResponse<any>) => void) { + instance.delete('/ui/project/build/' + environment + "/" + buildName) + .then(res => { + if (res.status === 200) { + after(res.data); + } + }).catch(err => { + ErrorEventBus.sendApiError(err); + }); + } + + static async getContainerLog(environment: string, name: string, after: (res: AxiosResponse<string>) => void) { + instance.get('/ui/container/log/' + environment + "/" + name) + .then(res => { + if (res.status === 200) { + after(res.data); + } + }).catch(err => { + ErrorEventBus.sendApiError(err); + }); + } + + static async getAllServiceStatuses(after: (statuses: ServiceStatus[]) => void) { + instance.get('/ui/infrastructure/service') + .then(res => { + if (res.status === 200) { + after(res.data); + } + }).catch(err => { + ErrorEventBus.sendApiError(err); + }); + } + + static async getAllContainerStatuses(after: (statuses: ContainerStatus[]) => void) { + instance.get('/ui/container') + .then(res => { + if (res.status === 200) { + after(res.data); + } + }).catch(err => { + ErrorEventBus.sendApiError(err); + }); + } + + static async getContainerStatus(projectId: string, after: (res: AxiosResponse<ContainerStatus[]>) => void) { + instance.get(`/ui/container/${projectId}`) + .then(res => { + after(res); + }).catch(err => { + after(err); + }); + } + + static async getAllDeploymentStatuses(): Promise<DeploymentStatus[]> { + try { + const res = await instance.get('/ui/infrastructure/deployment'); + return res.status === 200 ? res.data : []; + } catch (err: any) { + ErrorEventBus.sendApiError(err); + EventBus.sendAlert("Error", err?.message, "danger"); + return []; + } + } + + static async rolloutDeployment(projectId: string, environment: string, after: (res: AxiosResponse<any>) => void) { + instance.post('/ui/infrastructure/deployment/rollout/' + environment + '/' + projectId, "") + .then(res => { + after(res); + }).catch(err => { + after(err); + }); + } + + static async startDeployment(projectId: string, environment: string, after: (res: AxiosResponse<any>) => void) { + instance.post('/ui/infrastructure/deployment/start/' + environment + '/' + projectId, "") + .then(res => { + after(res); + }).catch(err => { + after(err); + }); + } + + static async deleteDeployment(environment: string, name: string, after: (res: AxiosResponse<any>) => void) { + instance.delete('/ui/infrastructure/deployment/' + environment + '/' + name) + .then(res => { + after(res); + }).catch(err => { + after(err); + }); + } + + static async manageContainer(projectId: string, + type: 'devmode' | 'packaged' | 'internal' | 'build' | 'unknown', + name: string, + command: 'deploy' | 'run' | 'pause' | 'stop' | 'delete', + pullImage: 'always' | 'ifNotExists' | 'never', + after: (res: AxiosResponse<any> | any) => void) { + instance.post('/ui/container/' + projectId + '/' + type + "/" + name, {command: command, pullImage: pullImage}) + .then(res => { + after(res); + }).catch(err => { + after(err); + }); + } + + static async deleteContainer(projectId: string, type: 'devmode' | 'packaged' | 'internal' | 'build' | 'unknown', name: string, after: (res: AxiosResponse<any>) => void) { + instance.delete('/ui/container/' + projectId + '/' + type + "/" + name) + .then(res => { + after(res); + }).catch(err => { + after(err); + }); + } + + static async getConfigMaps(after: (any: []) => void) { + instance.get('/ui/infrastructure/configmaps/') + .then(res => { + if (res.status === 200) { + after(res.data); + } + }).catch(err => { + ErrorEventBus.sendApiError(err); + }); + } + + static async getImages(projectId: string, after: (string: []) => void) { + instance.get('/ui/image/project/' + projectId) + .then(res => { + if (res.status === 200) { + after(res.data); + } + }).catch(err => { + ErrorEventBus.sendApiError(err); + }); + } + + static async setProjectImage(projectId: string, imageName: string, commit: boolean, message: string, after: (res: AxiosResponse<any>) => void) { + instance.post('/ui/image/project/' + projectId, {imageName: imageName, commit: commit, message: message}) + .then(res => { + after(res); + }).catch(err => { + after(err); + }); + } + + static async deleteImage(imageName: string, after: () => void) { + instance.delete('/ui/image/project/' + Buffer.from(imageName).toString('base64')) + .then(res => { + if (res.status === 200) { + after(); + } + }).catch(err => { + ErrorEventBus.sendApiError(err); + }); + } + + static async pullProjectImages(projectId: string, after: (res: AxiosResponse<any>) => void) { + const params = { + 'projectId': projectId, + 'userId': getCurrentUser()?.username + }; + instance.post('/ui/image/pull/', params) + .then(res => { + after(res); + }).catch(err => { + after(err); + }); + } + + static async getSecrets(after: (any: []) => void) { + instance.get('/ui/infrastructure/secrets') + .then(res => { + if (res.status === 200) { + after(res.data); + } + }).catch(err => { + ErrorEventBus.sendApiError(err); + }); + } + + static async getServices(after: (any: []) => void) { + instance.get('/ui/infrastructure/services') + .then(res => { + if (res.status === 200) { + after(res.data); + } + }).catch(err => { + ErrorEventBus.sendApiError(err); + }); + } + + static async deleteAllStatuses(after: (res: AxiosResponse<any>) => void) { + instance.delete('/ui/status/all/') + .then(res => { + after(res); + }).catch(err => { + after(err); + }); + } + + static async restartInformers(after: (res: AxiosResponse<any>) => void) { + instance.put('/ui/infrastructure/informers/') + .then(res => { + after(res); + }).catch(err => { + after(err); + }); + } + + static async getCustomKamelets(after: (yaml: string) => void) { + instance.get('/ui/metadata/kamelets/kamelets', {headers: {'Accept': 'text/plain'}, timeout: 0}) + .then(res => { + if (res.status === 200) { + after(res.data); + } + }).catch(err => { + ErrorEventBus.sendApiError(err); + }); + } + + static async getComponents(after: (json: string) => void) { + instance.get('/ui/metadata/components', {timeout: 0}) + .then(res => { + if (res.status === 200) { + after(JSON.stringify(res.data)); + } + }).catch(err => { + ErrorEventBus.sendApiError(err); + }); + } + + static async getBeans(after: (json: string) => void) { + instance.get('/ui/metadata/beans', {timeout: 0}) + .then(res => { + if (res.status === 200) { + after(JSON.stringify(res.data)); + } + }).catch(err => { + ErrorEventBus.sendApiError(err); + }); + } + + static async getMetadataConfiguration(configName: string, after: (json: string) => void) { + instance.get(`/ui/metadata/${configName}Configuration`, { timeout: 0 }) + .then(res => { + if (res.status === 200) { + after(JSON.stringify(res.data)); + } + }).catch(err => { + ErrorEventBus.sendApiError(err); + }); + } + + static async getConfigurationChanges(after: (json: string) => void) { + instance.get('/ui/metadata/configurationChanges', {timeout: 0}) + .then(res => { + if (res.status === 200) { + after(JSON.stringify(res.data)); + } + }).catch(err => { + ErrorEventBus.sendApiError(err); + }); + } + + static async getProjectsActivities(after: (activities?: any) => void) { + instance.get('/ui/activity/projects') + .then(res => { + if (res.status === 200) { + after(res.data); + } else { + after(undefined); + } + }).catch(err => { + ErrorEventBus.sendApiError(err); + after(undefined); + }); + } + + static async getUsersActivities(after: (activities?: any) => void) { + instance.get('/ui/activity/users') + .then(res => { + if (res.status === 200) { + after(res.data); + } else { + after(undefined); + } + }).catch(err => { + ErrorEventBus.sendApiError(err); + after(undefined); + }); + } + + static async getProjectsLabels(after: (labels?: any) => void) { + instance.get('/ui/labels') + .then(res => { + if (res.status === 200) { + after(res.data); + } else { + after(undefined); + } + }).catch(err => { + ErrorEventBus.sendApiError(err); + after(undefined); + }); + } +} diff --git a/karavan-app/src/main/webui/src/api/KubernetesApi.ts b/karavan-app/src/main/webui/src/api/KubernetesApi.ts new file mode 100644 index 00000000..1a1c2881 --- /dev/null +++ b/karavan-app/src/main/webui/src/api/KubernetesApi.ts @@ -0,0 +1,20 @@ +import {AuthApi} from "./auth/AuthApi"; +import {ErrorEventBus} from "@bus/ErrorEventBus"; +import {PodEvent} from "@models/ProjectModels"; + +const instance = AuthApi.getInstance(); + +export class KubernetesApi { + + static async getPodEvents(containerName: string): Promise<PodEvent[]> { + try { + const res = await instance.get(`/ui/infrastructure/pod-events/${containerName}`, { + headers: { 'Accept': 'application/json' } + }); + return res.status === 200 ? res.data : []; + } catch (err) { + ErrorEventBus.sendApiError(err); + return []; + } + } +} \ No newline at end of file diff --git a/karavan-app/src/main/webui/src/api/LogWatchApi.tsx b/karavan-app/src/main/webui/src/api/LogWatchApi.tsx new file mode 100644 index 00000000..e4f9a6ba --- /dev/null +++ b/karavan-app/src/main/webui/src/api/LogWatchApi.tsx @@ -0,0 +1,77 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {fetchEventSource} from "@microsoft/fetch-event-source"; +import {LogsEventBus} from "@bus/LogsEventBus"; +import {getCurrentUser} from "./auth/AuthApi"; +import {authFetch, sseRetryInterval} from "./auth/AuthFetch"; + +export class LogWatchApi { + + static async fetchData(type: 'container' | 'build' | 'none', podName: string, controller: AbortController) { + console.log("Fetch Started for: " + podName); + const fetchData = async () => { + const headers: Record<string, string> = { + Accept: "text/event-stream", + }; + const url = `/ui/logwatch/${type}/${podName}/${getCurrentUser()?.username ?? ""}`; + // The Authorization header is added by `authFetch` on every attempt so + // that reconnects use a freshly refreshed token instead of replaying + // the token captured when the stream was first opened. + let attempt = 0; + await fetchEventSource(url, { + method: "GET", headers: headers, signal: controller.signal, credentials: "include", + fetch: authFetch, + async onopen(response) { + attempt = 0; + const ct = response.headers.get("content-type") || ""; + if (response.ok && ct.toLowerCase().startsWith("text/event-stream")) { + return; // good to go + } + // Handle auth and other errors explicitly + if (response.status === 401) { + console.warn("SSE unauthorized: session missing/expired."); + // Optional: trigger a global event/router redirect here + throw new Error("unauthorized"); + } + console.error("Unexpected SSE response", response.status, ct); + throw new Error(`bad-sse-response:${response.status}`); + }, + onmessage(event) { + if (event.event !== 'ping') { + LogsEventBus.sendLog('add', event.data); + } else { + console.log('Logger SSE Ping', event); + } + }, + onclose() { + console.log("Connection closed by the server"); + }, + onerror(err) { + // Never retry an auth failure: the token was already refreshed + // before this attempt, so retrying only floods the backend. + if (err instanceof Error && err.message === "unauthorized") { + throw err; + } + console.log("There was an error from server", err); + return sseRetryInterval(attempt++); + }, + }); + }; + return fetchData(); + } +} diff --git a/karavan-app/src/main/webui/src/api/NotificationApi.tsx b/karavan-app/src/main/webui/src/api/NotificationApi.tsx new file mode 100644 index 00000000..7ce5fa2f --- /dev/null +++ b/karavan-app/src/main/webui/src/api/NotificationApi.tsx @@ -0,0 +1,105 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {EventStreamContentType, fetchEventSource} from "@microsoft/fetch-event-source"; +import {EventSourceMessage} from "@microsoft/fetch-event-source/lib/cjs/parse"; +import {KaravanEvent, NotificationEventBus} from "@services/NotificationService"; +import {getCurrentUser} from "./auth/AuthApi"; +import {authFetch, sseRetryInterval} from "./auth/AuthFetch"; + +export class NotificationApi { + + static getKaravanEvent (ev: EventSourceMessage, type: 'system' | 'user') { + const eventParts = ev.event?.split(':'); + const event = eventParts?.length > 1 ? eventParts[0] : undefined; + const className = eventParts?.length > 1 ? eventParts[1] : undefined; + return new KaravanEvent({id: ev.id, event: event, type: type, className: className, data: JSON.parse(ev.data)}); + } + + static onSystemMessage (ev: EventSourceMessage) { + const ke = NotificationApi.getKaravanEvent(ev, 'system'); + NotificationEventBus.sendEvent(ke); + } + + static onUserMessage (ev: EventSourceMessage) { + const ke = NotificationApi.getKaravanEvent(ev, 'user'); + NotificationEventBus.sendEvent(ke); + } + + static async notification(controller: AbortController) { + const fetchData = async () => { + // No Authorization header here on purpose: `authFetch` adds a freshly + // refreshed token per connection attempt, so reconnects never replay + // the token that was current when the stream was first opened. + const headers: any = { Accept: "text/event-stream" }; + if (getCurrentUser()) { + NotificationApi.fetch('/ui/notification/system/' + getCurrentUser()?.username, controller, headers, + ev => NotificationApi.onSystemMessage(ev)); + NotificationApi.fetch('/ui/notification/user/' + getCurrentUser()?.username, controller, headers, + ev => NotificationApi.onUserMessage(ev)); + } + }; + return fetchData(); + }; + + static async fetch(input: string, controller: AbortController, headers: any, onmessage: (ev: EventSourceMessage) => void) { + let attempt = 0; + fetchEventSource(input, { + method: "GET", + headers: headers, + signal: controller.signal, + credentials: "include", + fetch: authFetch, + async onopen(response) { + if (response.ok && response.headers.get('content-type') === EventStreamContentType) { + attempt = 0; + return; // everything's good + } else if (response.status === 401) { + console.warn("SSE unauthorized: session missing/expired."); + // Optional: trigger a global event/router redirect here + throw new Error("unauthorized"); + } else if (response.status >= 400 && response.status < 500 && response.status !== 429) { + // client-side errors are usually non-retriable: + console.error("Server side error ", response); + // EventBus.sendAlert("Error fetching", `${input} : ${response.statusText}`, "danger"); + } else { + console.error("Error ", response); + // EventBus.sendAlert("Error fetching", `${input} : ${response.statusText}`, "danger"); + } + }, + onmessage(event) { + if (event.event !== 'ping') { + onmessage(event); + } else { + console.log('Notification SSE Ping', event); + } + }, + onclose() { + console.log("Connection closed by the server"); + }, + onerror(err) { + // Never retry an auth failure: the token was already refreshed + // before this attempt, so retrying only floods the backend. + if (err instanceof Error && err.message === "unauthorized") { + throw err; + } + console.log("There was an error from server", err); + return sseRetryInterval(attempt++); + }, + }); + } +} diff --git a/karavan-app/src/main/webui/src/api/SearchApi.tsx b/karavan-app/src/main/webui/src/api/SearchApi.tsx new file mode 100644 index 00000000..cffd280b --- /dev/null +++ b/karavan-app/src/main/webui/src/api/SearchApi.tsx @@ -0,0 +1,26 @@ +import axios from "axios"; +import {ErrorEventBus} from "@bus/ErrorEventBus"; +import {SearchResult} from "@models/SearchModels"; +import {AuthApi} from "./auth/AuthApi"; + +axios.defaults.headers.common['Accept'] = 'application/json'; +axios.defaults.headers.common['Content-Type'] = 'application/json'; +const instance = AuthApi.getInstance(); + +export class SearchApi { + + static async searchAll(string: string, after: (result?: SearchResult[]) => void) { + const encoded = encodeURIComponent(string); + instance.get('/ui/search/all/' + encoded) + .then(res => { + if (res.status === 200) { + after(res.data); + } else { + after(undefined); + } + }).catch(err => { + ErrorEventBus.sendApiError(err); + after(undefined); + }); + } +} diff --git a/karavan-app/src/main/webui/src/api/SystemApi.tsx b/karavan-app/src/main/webui/src/api/SystemApi.tsx new file mode 100644 index 00000000..4f325d93 --- /dev/null +++ b/karavan-app/src/main/webui/src/api/SystemApi.tsx @@ -0,0 +1,194 @@ +import axios from "axios"; +import {AuthApi} from "./auth/AuthApi"; +import {ErrorEventBus} from "@bus/ErrorEventBus"; +import {KubernetesConfigMap, KubernetesSecret} from "@models/SystemModels"; +import {Buffer} from 'buffer'; + +axios.defaults.headers.common['Accept'] = 'application/json'; +axios.defaults.headers.common['Content-Type'] = 'application/json'; +const instance = AuthApi.getInstance(); + +export class SystemApi { + + // Secrets + static async createSecret(secretName: string, after: (val: string) => void) { + instance.post('/platform/system/secrets/' + Buffer.from(secretName).toString('base64'), {},{headers: {'Content-Type': 'text/plain'}}) + .then(res => { + if (res.status === 200) { + after(res.data); + } + }).catch(err => { + ErrorEventBus.sendApiError(err); + }); + } + + static async getSecrets(after: (secrets: KubernetesSecret[]) => void) { + instance.get('/platform/system/secrets', {headers: {'Accept': 'application/json'}}) + .then(res => { + if (res.status === 200) { + after(res.data); + } + }).catch(err => { + ErrorEventBus.sendApiError(err); + }); + } + + static async deleteSecret(secretName: string, after: (val: string) => void) { + instance.delete('/platform/system/secrets/' + Buffer.from(secretName).toString('base64')) + .then(res => { + if (res.status === 204) { + after(res.data); + } + }).catch(err => { + ErrorEventBus.sendApiError(err); + }); + } + + static async getSecretValue(secretName: string, secretKey: string, after: (val: string) => void) { + instance.get('/platform/system/secrets/' + Buffer.from(secretName).toString('base64') + '/' + Buffer.from(secretKey).toString('base64'), {headers: {'Accept': 'application/json'}}) + .then(res => { + if (res.status === 200) { + after(res.data); + } + }).catch(err => { + ErrorEventBus.sendApiError(err); + }); + } + + static async setSecretValue(secretName: string, secretKey: string, value: string, after: (val: string) => void) { + instance.post('/platform/system/secrets/' + Buffer.from(secretName).toString('base64') + '/' + Buffer.from(secretKey).toString('base64'), + Buffer.from(value, 'binary').toString('base64'), + {headers: {'Content-Type': 'text/plain'}}) + .then(res => { + if (res.status === 200) { + after(res.data); + } + }).catch(err => { + ErrorEventBus.sendApiError(err); + }); + } + + static async deleteSecretValue(secretName: string, secretKey: string, after: (val: string) => void) { + instance.delete('/platform/system/secrets/' + Buffer.from(secretName).toString('base64') + '/' + Buffer.from(secretKey).toString('base64')) + .then(res => { + if (res.status === 204) { + after(res.data); + } + }).catch(err => { + ErrorEventBus.sendApiError(err); + }); + } + + // ConfigMaps + static async createConfigMap(secretName: string, after: (val: string) => void) { + instance.post('/platform/system/configmaps/' + Buffer.from(secretName).toString('base64'), {},{headers: {'Content-Type': 'text/plain'}}) + .then(res => { + if (res.status === 200) { + after(res.data); + } + }).catch(err => { + ErrorEventBus.sendApiError(err); + }); + } + + static async getConfigMaps(after: (secrets: KubernetesConfigMap[]) => void) { + instance.get('/platform/system/configmaps', {headers: {'Accept': 'application/json'}}) + .then(res => { + if (res.status === 200) { + after(res.data); + } + }).catch(err => { + ErrorEventBus.sendApiError(err); + }); + } + + static async deleteConfigMap(configmapName: string, after: (val: string) => void) { + instance.delete('/platform/system/configmaps/' + Buffer.from(configmapName).toString('base64')) + .then(res => { + if (res.status === 204) { + after(res.data); + } + }).catch(err => { + ErrorEventBus.sendApiError(err); + }); + } + + static async getConfigMapValue(configmapName: string, configmapKey: string, after: (val: string) => void) { + instance.get('/platform/system/configmaps/' + Buffer.from(configmapName).toString('base64') + '/' + Buffer.from(configmapKey).toString('base64'), {headers: {'Accept': 'application/json'}}) + .then(res => { + if (res.status === 200) { + after(res.data); + } + }).catch(err => { + ErrorEventBus.sendApiError(err); + }); + } + + static async setConfigMapValue(configmapName: string, configmapKey: string, value: string, after: (val: string) => void) { + instance.post('/platform/system/configmaps/' + Buffer.from(configmapName).toString('base64') + '/' + Buffer.from(configmapKey).toString('base64'), value, {headers: {'Content-Type': 'text/plain'}}) + .then(res => { + if (res.status === 200) { + after(res.data); + } + }).catch(err => { + ErrorEventBus.sendApiError(err); + }); + } + + static async deleteConfigMapValue(configmapName: string, configmapKey: string, after: (val: string) => void) { + instance.delete('/platform/system/configmaps/' + Buffer.from(configmapName).toString('base64') + '/' + Buffer.from(configmapKey).toString('base64')) + .then(res => { + if (res.status === 204) { + after(res.data); + } + }).catch(err => { + ErrorEventBus.sendApiError(err); + }); + } + + // Env Vars + static async getEnvVars(after: (envVars: string[]) => void) { + instance.get('/ui/diagnostics/env-vars', {headers: {'Accept': 'application/json'}}) + .then(res => { + if (res.status === 200) { + after(res.data); + } + }).catch(err => { + ErrorEventBus.sendApiError(err); + }); + } + + static async getEnvVarValue(name: string, after: (val: string) => void) { + instance.get('/ui/diagnostics/env-vars/' + Buffer.from(name).toString('base64'), {headers: {'Accept': 'application/json'}}) + .then(res => { + if (res.status === 200) { + after(res.data); + } + }).catch(err => { + ErrorEventBus.sendApiError(err); + }); + } + + // Application Properties + static async getAppProps(after: (envVars: string[]) => void) { + instance.get('/ui/diagnostics/app-props', {headers: {'Accept': 'application/json'}}) + .then(res => { + if (res.status === 200) { + after(res.data); + } + }).catch(err => { + ErrorEventBus.sendApiError(err); + }); + } + + static async getAppPropValue(name: string, after: (val: string) => void) { + instance.get('/ui/diagnostics/app-props/' + Buffer.from(name).toString('base64'), {headers: {'Accept': 'application/json'}}) + .then(res => { + if (res.status === 200) { + after(res.data); + } + }).catch(err => { + ErrorEventBus.sendApiError(err); + }); + } +} diff --git a/karavan-app/src/main/webui/src/api/auth/AuthApi.tsx b/karavan-app/src/main/webui/src/api/auth/AuthApi.tsx new file mode 100644 index 00000000..7be2e164 --- /dev/null +++ b/karavan-app/src/main/webui/src/api/auth/AuthApi.tsx @@ -0,0 +1,253 @@ +import axios from "axios"; +import {ErrorEventBus} from "@bus/ErrorEventBus"; +import {AccessPassword, AccessUser} from "@models/AccessModels"; +import {SsoApi} from "./SsoApi"; // --- axios base --- + +// --- axios base --- +axios.defaults.timeout = 30000; +axios.defaults.headers.common["Accept"] = "application/json"; +axios.defaults.headers.common["Content-Type"] = "application/json"; + +const instance = axios.create({ withCredentials: true }); + +// --- simple state (no tokens) --- +let currentUser: AccessUser | null = null; +export function setCurrentUser(u: AccessUser | null) { currentUser = u; } +export function getCurrentUser() { return currentUser; } +export function getInstance() { return instance; } + +// --- cookies / csrf --- +const CSRF_COOKIE = "csrf"; +function readCookie(name: string): string | null { + return document.cookie + .split("; ") + .map((p) => p.trim()) + .filter((p) => p.startsWith(name + "=")) + .map((p) => p.substring(name.length + 1))[0] ?? null; +} + +function isNoAuth(cfg: any) { + const method = (cfg.method || "GET").toUpperCase(); + const url = new URL(cfg.url!, cfg.baseURL || window.location.origin); + const path = url.pathname; + // Endpoints where we intentionally skip CSRF/auth headers + return ( + cfg.headers?.["X-Skip-Auth"] === "1" || + method === "OPTIONS" || + path.endsWith("/ui/auth/login") || + path.endsWith("/ui/auth/logout") || + path.endsWith("/health") || + path.endsWith("/q/health") + ); +} + +// --- API surface (no tokens involved) --- +export class AuthApi { + static authType?: "session" | "oidc"; + static getInstance() { + return instance; + } + + static async getMe(after: (user: AccessUser | null) => void) { + instance + .get("/ui/auth/me", { withCredentials: true }) + .then((res) => { + if (res.status === 200) { + setCurrentUser(res.data); + after(res.data); + } else { + setCurrentUser(null); + after(null); + } + }) + .catch((err) => { + // 401 here means "not logged in" + setCurrentUser(null); + // Always call back: callers use it to leave their "loading" state, + // and staying loading forever would hide the login page. + after(null); + // optional: bubble error to a global bus/router + // ErrorEventBus.sendApiError(err); + }); + } + + static async login( + username: string, + password: string, + after: (ok: boolean, res: any) => void + ) { + instance + .post("/ui/auth/login", { username, password }, { withCredentials: true }) + .then((res) => { + if (res.status === 200) { + // server may return user; if not, fetch it + if (res.data?.username) { + setCurrentUser(res.data.username); + after(true, res); + } else { + AuthApi.getMe(() => after(true, res)); + } + } else { + setCurrentUser(null); + after(false, res); + } + }) + .catch((err) => { + setCurrentUser(null); + after(false, err); + }); + } + + static async logout() { + // Tell interceptors to skip CSRF on logout + setCurrentUser(null); + instance + .post( + "/ui/auth/logout", + {}, + { withCredentials: true, headers: { "X-Skip-Auth": "1" } } + ) + .then((res) => { + if (res.status === 204) { + setCurrentUser(null); + } + }) + .catch((err) => { + console.error(err); + setCurrentUser(null); + }); + } + + + static setPassword(password: AccessPassword, after: (result: boolean, res: any) => void) { + instance + .post("/ui/auth/password", password) + .then((res) => { + if (res.status === 200 || res.status === 201) after(true, res); + else after(false, res); + }) + .catch((err) => { + console.error(err); + after(false, err); + }); + } + + // Optional: keep if your UI still reads SSO config (even though auth is session-based) + static async getSsoConfig(after: (config: {}) => void) { + instance.get("/ui/auth/sso-config", { headers: { Accept: "application/json" } }) + .then((res) => { + if (res.status === 200) after(res.data); + }) + .catch((err) => { + ErrorEventBus.sendApiError(err); + }); + } + + static setAuthType(authType: "session" | "oidc") { + this.authType = authType; + switch (authType) { + case "oidc": + AuthApi.setOidcAuthentication(); + break; + case "session": + AuthApi.setSessionIdAuthentication(); + break; + } + } + + static async getAuthType(after: (authType: string) => void) { + instance + .get("/ui/auth/type", { headers: { Accept: "text/plain" } }) + .then((res) => { + if (res.status === 200) { + const authType = res.data as "session" | "oidc"; + AuthApi.setAuthType(authType); + after(authType); + } + }) + .catch((err) => { + ErrorEventBus.sendApiError(err); + }); + } + + private static setSessionIdAuthentication() { + // --- request interceptor: add CSRF header on unsafe methods, never set Authorization --- + instance.interceptors.request.use((cfg: any) => { + // ensure no Authorization header sneaks in + if (cfg?.headers?.Authorization) delete cfg.headers.Authorization; + + if (!isNoAuth(cfg)) { + const m = (cfg.method || "GET").toLowerCase(); + const unsafe = m === "post" || m === "put" || m === "patch" || m === "delete"; + if (unsafe) { + const csrf = readCookie(CSRF_COOKIE); + if (csrf) cfg.headers = { ...cfg.headers, "X-CSRF-Token": csrf }; + } + } + return cfg; + }); + +// --- response interceptor: normalize 401 handling --- + instance.interceptors.response.use( + (res) => res, + async (error) => { + const original = error?.config; + if (!original) throw error; + + if (error?.response?.status === 401 && !original._retry && !isNoAuth(original)) { + original._retry = true; + // Session missing/expired → clear user; let caller redirect to /login + setCurrentUser(null); + // Optionally: return a rejected promise with a sentinel + return Promise.reject({ ...error, _auth: "unauthorized" }); + } + + return Promise.reject(error); + } + ); + } + + static setOidcAuthentication() { + instance.interceptors.request.use( + async (config) => { + // Refresh proactively: reading `keycloak.token` directly sends an + // already-expired JWT for every request made after the access + // token lifespan (300s in the realm config) elapsed. + const token = await SsoApi.getValidToken(); + if (token) config.headers.Authorization = `Bearer ${token}`; + return config; + }, + (error) => Promise.reject(error) + ); + + instance.interceptors.response.use( + (response) => response, + async (error) => { + const original = error.config; + if (!original || original._retry) return Promise.reject(error); + + const status = error?.response?.status; + if ((status === 401 || status === 403) && SsoApi.keycloak) { + original._retry = true; + try { + // Backstop only - the request interceptor should already + // have refreshed. Force a refresh (-1 = regardless of the + // remaining validity) to cover a token revoked server-side. + await SsoApi.updateToken(-1); + const token = SsoApi.keycloak.token; + if (token) { + original.headers = { + ...original.headers, + Authorization: `Bearer ${token}`, + }; + return instance(original); + } + } catch (e) { + // fall-through + } + } + return Promise.reject(error); + } + ); + } +} diff --git a/karavan-app/src/main/webui/src/api/auth/AuthFetch.ts b/karavan-app/src/main/webui/src/api/auth/AuthFetch.ts new file mode 100644 index 00000000..da8dee82 --- /dev/null +++ b/karavan-app/src/main/webui/src/api/auth/AuthFetch.ts @@ -0,0 +1,53 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {AuthApi} from "./AuthApi"; +import {SsoApi} from "./SsoApi"; + +/** + * `fetchEventSource` snapshots its `headers` option once and reuses that same + * object for every automatic reconnect. With OIDC that means the very first + * access token is replayed forever, so once it expires each reconnect makes the + * backend log "The JWT is no longer valid". + * + * Passing this as the `fetch` option instead makes the Authorization header be + * rebuilt on every attempt - including reconnects - from a token that is + * refreshed first when it is close to expiry. + */ +export function authFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> { + if (AuthApi.authType !== 'oidc') { + return window.fetch(input, init); + } + return SsoApi.getValidToken().then(token => { + const headers = new Headers(init?.headers); + if (token) { + headers.set('Authorization', 'Bearer ' + token); + } else { + headers.delete('Authorization'); + } + return window.fetch(input, {...init, headers}); + }); +} + +/** + * Reconnect backoff for SSE streams. `fetchEventSource` retries every second by + * default, which turns any persistent failure into a request flood (and a WARN + * flood in the backend log). Returned from `onerror` to space attempts out. + */ +export function sseRetryInterval(attempt: number): number { + return Math.min(1000 * Math.pow(2, attempt), 30000); +} diff --git a/karavan-app/src/main/webui/src/api/auth/AuthProvider.tsx b/karavan-app/src/main/webui/src/api/auth/AuthProvider.tsx new file mode 100644 index 00000000..2e11e5fb --- /dev/null +++ b/karavan-app/src/main/webui/src/api/auth/AuthProvider.tsx @@ -0,0 +1,103 @@ +import React, {useCallback, useEffect, useState} from "react"; +// Import all the APIs we need +import {AuthApi, getCurrentUser} from "./AuthApi"; +import {SsoApi} from "./SsoApi"; +import {AccessUser} from "@models/AccessModels"; // Assuming AccessUser is here + +// We'll add `authType` to the context for consumers +export const AuthContext = React.createContext({ + user: null as AccessUser | null, + loading: true, + authType: null as "session" | "oidc" | null, + reload: async () => {}, + logout: async () => {}, +}); + +export function AuthProvider({ children }: { children: React.ReactNode }) { + const [user, setUser] = useState<AccessUser | null>(null); + const [loading, setLoading] = useState(true); + const [authType, setAuthType] = useState<"session" | "oidc" | null>(null); + + // This is the main initialization effect. + // It replaces the simple `useEffect(() => { reload() }, [reload])` + useEffect(() => { + let isMounted = true; // Prevent state updates on unmounted component + // 1. Fetch the authentication type. + // This call also sets up the correct axios interceptors inside AuthApi. + AuthApi.getAuthType(type => { + if (!isMounted) return; + + setAuthType(type as "session" | "oidc" | null); // Save the type + + if (type === 'oidc') { + // 2a. OIDC Flow: Initialize Keycloak. + // SsoApi.auth() handles the 'check-sso' logic. + SsoApi.auth(() => { + // This callback runs after Keycloak init (login or silent check) + if (isMounted) { + // SsoApi.auth() has updated the global currentUser + setUser(getCurrentUser()); + setLoading(false); + } + }); + } else { + // 2b. Session Flow: Just call /me to check for an existing cookie. + AuthApi.getMe(userFromApi => { + if (isMounted) { + // AuthApi.getMe() updates global user and returns it + setUser(userFromApi); + setLoading(false); + } + }); + } + }); + + // Cleanup function in case component unmounts during auth + return () => { isMounted = false; }; + }, []); // Run only once on mount + + // The original `reload` was flawed (awaiting a non-promise). + // This implementation matches the callback-style of AuthApi.getMe. + const reload = useCallback(async () => { + console.log("reload", getCurrentUser()); + setLoading(true); + // getMe will use the correct interceptor (OIDC or session) + // because getAuthType() already ran and set it up. + AuthApi.getMe((u) => { + setUser(u); + setLoading(false); + }); + // We keep the `async` signature to match the context interface, + // even though the implementation is callback-based. + }, []); + + // The logout function MUST now be conditional + const logout = useCallback(async () => { + setLoading(true); + if (authType === 'oidc') { + // Use OIDC logout + SsoApi.logout(() => { + setUser(null); + setLoading(false); + // OIDC logout often involves a page redirect, + // which SsoApi.logout() will trigger. + }); + } else if (authType === 'session') { + // Use session logout + await AuthApi.logout(); // This is async + setUser(null); + setLoading(false); + window.location.reload(); + } else { + // Fallback if authType isn't set for some reason + setUser(null); + setLoading(false); + } + }, [authType]); // Re-create this function if authType changes + + return ( + <AuthContext.Provider value={{ user, loading, authType, reload, logout }}> + {children} + </AuthContext.Provider> + ); +} \ No newline at end of file diff --git a/karavan-app/src/main/webui/src/api/auth/SsoApi.tsx b/karavan-app/src/main/webui/src/api/auth/SsoApi.tsx new file mode 100644 index 00000000..447b9fc5 --- /dev/null +++ b/karavan-app/src/main/webui/src/api/auth/SsoApi.tsx @@ -0,0 +1,157 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Keycloak from "keycloak-js"; +import {AuthApi, setCurrentUser} from "./AuthApi"; +import {AccessUser} from "@models/AccessModels"; + +// Refresh the access token when it has less than this many seconds left. +// Must be comfortably larger than the polling/SSE reconnect period so a token +// never reaches the backend after its `exp`. +const MIN_VALIDITY_SECONDS = 70; + +// How often the background watchdog checks the token. Keycloak only fires +// `onTokenExpired` while the tab is alive; the interval also covers the case +// where the tab was suspended (laptop sleep, background tab throttling). +const REFRESH_CHECK_MS = 20_000; + +export class SsoApi { + + static keycloak?: Keycloak; + + private static refreshTimer?: number; + // De-duplicates concurrent refreshes: dozens of pollers can ask for a token + // in the same tick, and Keycloak must not be hit with parallel refresh calls + // (with `refreshTokenMaxReuse=0` the losers would invalidate the session). + private static refreshPromise?: Promise<boolean>; + + static auth(after: () => void) { + AuthApi.getSsoConfig((config: any) => { + SsoApi.keycloak = new Keycloak({url: config.url, realm: config.realm, clientId: config.clientId}); + SsoApi.keycloak.onTokenExpired = () => { + console.log('SsoApi', 'Access token expired, refreshing.'); + SsoApi.updateToken().catch(reason => console.log('SsoApi', 'Refresh on expiry failed:', reason)); + }; + SsoApi.keycloak.init({ + flow: "standard", + pkceMethod: "S256", + onLoad: 'login-required', + checkLoginIframe: false, + silentCheckSsoRedirectUri: `${location.origin}/silent-check-sso.html` + }).then(authenticated => { + if (authenticated) { + const k = SsoApi.keycloak; + if (k) { + const userInfo = { + username: k.tokenParsed?.preferred_username, + roles: k.tokenParsed?.realm_access?.roles || [], + }; + console.log('SsoApi', 'User is now authenticated.', userInfo); + setCurrentUser(userInfo as AccessUser); + SsoApi.startRefreshWatchdog(); + } + } else { + console.log('User is not authenticated'); + } + after(); + }).catch(reason => { + console.log('SsoApi', 'Error:', reason); + // Still notify the caller so it can leave its "loading" state + // instead of hanging on a blank screen. + after(); + }); + }); + } + + /** + * Refreshes the access token if it expires within `MIN_VALIDITY_SECONDS`. + * Concurrent callers share a single in-flight refresh. + * Resolves to true when a new token was actually fetched. + */ + static updateToken(minValidity: number = MIN_VALIDITY_SECONDS): Promise<boolean> { + const k = SsoApi.keycloak; + if (!k?.authenticated) { + return Promise.resolve(false); + } + if (!SsoApi.refreshPromise) { + SsoApi.refreshPromise = k.updateToken(minValidity) + .catch(reason => { + // The refresh token itself is gone (SSO idle timeout / session + // revoked). Nothing to salvage: bounce the user to the IdP + // instead of hammering the backend with a dead access token. + console.log('SsoApi', 'Token refresh failed, re-authenticating:', reason); + SsoApi.stopRefreshWatchdog(); + setCurrentUser(null); + k.login(); + return false; + }) + .finally(() => { + SsoApi.refreshPromise = undefined; + }); + } + return SsoApi.refreshPromise; + } + + /** + * Single entry point for anything that needs to put a bearer token on a + * request. Always returns a token that is still valid, refreshing first + * when needed, so an expired JWT never reaches the backend. + */ + static async getValidToken(): Promise<string | undefined> { + const k = SsoApi.keycloak; + if (!k?.authenticated) { + return undefined; + } + await SsoApi.updateToken(); + return SsoApi.keycloak?.token; + } + + private static startRefreshWatchdog() { + SsoApi.stopRefreshWatchdog(); + SsoApi.refreshTimer = window.setInterval(() => { + SsoApi.updateToken().catch(reason => console.log('SsoApi', 'Scheduled refresh failed:', reason)); + }, REFRESH_CHECK_MS); + // A suspended tab misses its intervals; re-check as soon as it is visible again. + document.addEventListener('visibilitychange', SsoApi.onVisibilityChange); + } + + private static stopRefreshWatchdog() { + if (SsoApi.refreshTimer !== undefined) { + window.clearInterval(SsoApi.refreshTimer); + SsoApi.refreshTimer = undefined; + } + document.removeEventListener('visibilitychange', SsoApi.onVisibilityChange); + } + + private static onVisibilityChange = () => { + if (!document.hidden) { + SsoApi.updateToken().catch(reason => console.log('SsoApi', 'Refresh on resume failed:', reason)); + } + }; + + static logout(after: () => void) { + if (SsoApi.keycloak) { + SsoApi.stopRefreshWatchdog(); + SsoApi.keycloak.logout().then(value => { + console.log('SsoApi', 'User is now logout.'); + setCurrentUser(null) + }).catch(reason => { + console.log('SsoApi', 'Error:', reason); + }); + } + } +}
