diff --git a/backend/app/main.py b/backend/app/main.py index 644d60e..ccec2e6 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,4 +1,4 @@ -from fastapi import FastAPI, Request, APIRouter +from fastapi import FastAPI, Request, APIRouter, Depends from fastapi.responses import JSONResponse, Response from fastapi.encoders import jsonable_encoder from fastapi.exceptions import RequestValidationError @@ -6,6 +6,7 @@ from fastapi.middleware.cors import CORSMiddleware from starlette.middleware.sessions import SessionMiddleware from .routers import auth, useCases, subUseCases, roles, transactions, properties, propertyGroups, users, ontology, standards +from .crud.auth import get_current_user_from_token from .config import IMAGE_DIR, get_settings from .exceptions import VersionConflictError import logging @@ -76,15 +77,16 @@ async def validation_exception_handler(request: Request, exc: RequestValidationE secret_key=get_settings().session_secret_key, ) -api = APIRouter(prefix="/api") +api = APIRouter(prefix="/api", dependencies=[Depends(get_current_user_from_token)]) + -@api.get("/health") -def health() -> Response: - return Response(content="ok\n", media_type="text/plain") app.mount("/static/images", StaticFiles(directory=IMAGE_DIR), name="images") -api.include_router(auth.auth_router) +# Public auth endpoints (no token required) +app.include_router(auth.auth_router, prefix="/api") + +# Authenticated API routes api.include_router(users.user_router) api.include_router(useCases.router) api.include_router(subUseCases.router) @@ -95,3 +97,8 @@ def health() -> Response: api.include_router(ontology.router) api.include_router(standards.router) app.include_router(api) + +# Public health endpoint (no auth required) +@app.get("/api/health") +def health() -> Response: + return Response(content="ok\n", media_type="text/plain") diff --git a/backend/app/routers/properties.py b/backend/app/routers/properties.py index a514371..af94d46 100644 --- a/backend/app/routers/properties.py +++ b/backend/app/routers/properties.py @@ -2,7 +2,7 @@ from sqlalchemy.orm import Session from ..schemas.logs import VersionInfo from ..models.users import User -from ..crud.auth import get_current_user_from_token, require_role, RoleEnum +from ..crud.auth import require_role, RoleEnum from ..db import get_db from ..crud import properties as crud from ..schemas.properties import * @@ -37,13 +37,13 @@ def get_property_version(uuid: UUID, db: Session = Depends(get_db)): # --------CREATE------- @router.post("/", response_model=PropertyResponse, status_code=201) -def create_property(prop: PropertyCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user_from_token)): +def create_property(prop: PropertyCreate, db: Session = Depends(get_db), current_user: User = Depends(require_role(RoleEnum.WRITE))): return crud.create_property(db, prop, current_user.username) # --------UPDATE------- @router.put("/{uuid}", response_model=PropertyResponse) -def update_property(uuid: UUID, prop: PropertyUpdate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user_from_token)): +def update_property(uuid: UUID, prop: PropertyUpdate, db: Session = Depends(get_db), current_user: User = Depends(require_role(RoleEnum.WRITE))): updated = crud.update_property(db, uuid, prop, current_user.username) if not updated: raise HTTPException(status_code=404, detail="Property not found") @@ -52,7 +52,7 @@ def update_property(uuid: UUID, prop: PropertyUpdate, db: Session = Depends(get_ # --------DELETE------- @router.delete("/{uuid}", status_code=204) -def delete_property(uuid: UUID, db: Session = Depends(get_db), current_user: User = Depends(get_current_user_from_token)): +def delete_property(uuid: UUID, db: Session = Depends(get_db), current_user: User = Depends(require_role(RoleEnum.WRITE))): success = crud.delete_property(db, uuid, current_user.username) if not success: raise HTTPException(status_code=404, detail="Property not found") diff --git a/backend/app/routers/propertyGroups.py b/backend/app/routers/propertyGroups.py index b5b53af..0946825 100644 --- a/backend/app/routers/propertyGroups.py +++ b/backend/app/routers/propertyGroups.py @@ -2,7 +2,7 @@ from sqlalchemy.orm import Session from ..db import get_db from ..models.users import User -from ..crud.auth import get_current_user_from_token, require_role, RoleEnum +from ..crud.auth import require_role, RoleEnum from ..crud import propertyGroups as crud from ..schemas.propertyGroups import * from ..schemas.logs import VersionInfo @@ -43,12 +43,12 @@ def get_propertyGroup_version(uuid: UUID, db: Session = Depends(get_db)): # --------CREATE------- @router.post("/", response_model=PropertyGroupResponse, status_code=201) -def create_propertyGroup(property_group: PropertyGroupCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user_from_token)): +def create_propertyGroup(property_group: PropertyGroupCreate, db: Session = Depends(get_db), current_user: User = Depends(require_role(RoleEnum.WRITE))): return crud.create_propertyGroup(db, property_group, current_user.username) # --------UPDATE------- @router.put("/{uuid}", response_model=PropertyGroupResponse) -def update_propertyGroup(uuid: UUID, property_group: PropertyGroupUpdate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user_from_token)): +def update_propertyGroup(uuid: UUID, property_group: PropertyGroupUpdate, db: Session = Depends(get_db), current_user: User = Depends(require_role(RoleEnum.WRITE))): updated = crud.update_propertyGroup(db, uuid, property_group, current_user.username) if not updated: raise HTTPException(status_code=404, detail="Property Group not found") @@ -56,7 +56,7 @@ def update_propertyGroup(uuid: UUID, property_group: PropertyGroupUpdate, db: Se # --------DELETE------- @router.delete("/{uuid}", status_code=204) -def delete_propertyGroup(uuid: UUID, db: Session = Depends(get_db), current_user: User = Depends(get_current_user_from_token)): +def delete_propertyGroup(uuid: UUID, db: Session = Depends(get_db), current_user: User = Depends(require_role(RoleEnum.WRITE))): success = crud.delete_propertyGroup(db, uuid, current_user.username) if not success: raise HTTPException(status_code=404, detail="Property Group not found") diff --git a/backend/app/routers/roles.py b/backend/app/routers/roles.py index c654412..ad84dfc 100644 --- a/backend/app/routers/roles.py +++ b/backend/app/routers/roles.py @@ -1,7 +1,7 @@ from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.orm import Session from ..models.users import User -from ..crud.auth import get_current_user_from_token, require_role, RoleEnum +from ..crud.auth import require_role, RoleEnum from ..crud import roles as crud from ..db import get_db from ..schemas.roles import * @@ -31,12 +31,12 @@ def get_role_version(id: int, db: Session = Depends(get_db)): # ---------CREATE-------- @router.post("/", response_model=Role) -def create_role(data: RoleMutate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user_from_token)): +def create_role(data: RoleMutate, db: Session = Depends(get_db), current_user: User = Depends(require_role(RoleEnum.WRITE))): return crud.create_role(db, data, current_user.username) # ---------UPDATE-------- @router.put("/{id}", response_model=Role) -def update_role(id: int, data: RoleMutate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user_from_token)): +def update_role(id: int, data: RoleMutate, db: Session = Depends(get_db), current_user: User = Depends(require_role(RoleEnum.WRITE))): role = crud.get_role_by_id(db, id) if not role: raise HTTPException(status_code=404, detail="Rolle nicht gefunden") @@ -44,7 +44,7 @@ def update_role(id: int, data: RoleMutate, db: Session = Depends(get_db), curren # ---------DELETE-------- @router.delete("/{id}", status_code=204) -def delete_role(id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user_from_token)): +def delete_role(id: int, db: Session = Depends(get_db), current_user: User = Depends(require_role(RoleEnum.WRITE))): role = crud.get_role_by_id(db, id) if not role: raise HTTPException(status_code=404, detail="Rolle nicht gefunden") diff --git a/backend/app/routers/standards.py b/backend/app/routers/standards.py index 530f5ec..bba7aa9 100644 --- a/backend/app/routers/standards.py +++ b/backend/app/routers/standards.py @@ -3,7 +3,7 @@ from ..db import get_db from ..crud import standards as crud from ..models.users import User -from ..crud.auth import get_current_user_from_token, require_role, RoleEnum +from ..crud.auth import require_role, RoleEnum from ..schemas.standards import * from ..schemas.logs import VersionInfo @@ -31,12 +31,12 @@ def get_standard_version(id: int, db: Session = Depends(get_db)): # ---------CREATE-------- @router.post("/", response_model=Standard) -def create_standard(data: StandardMutate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user_from_token)): +def create_standard(data: StandardMutate, db: Session = Depends(get_db), current_user: User = Depends(require_role(RoleEnum.WRITE))): return crud.create_standard(db, data, current_user.username) # ---------UPDATE-------- @router.put("/{id}", response_model=Standard) -def update_standard(id: int, data: StandardMutate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user_from_token)): +def update_standard(id: int, data: StandardMutate, db: Session = Depends(get_db), current_user: User = Depends(require_role(RoleEnum.WRITE))): standard = crud.get_standard_by_id(db, id) if not standard: raise HTTPException(status_code=404, detail="Standard nicht gefunden") @@ -45,7 +45,7 @@ def update_standard(id: int, data: StandardMutate, db: Session = Depends(get_db) # ---------DELETE-------- @router.delete("/{id}", status_code=204) -def delete_standard(id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user_from_token)): +def delete_standard(id: int, db: Session = Depends(get_db), current_user: User = Depends(require_role(RoleEnum.WRITE))): standard = crud.get_standard_by_id(db, id) if not standard: raise HTTPException(status_code=404, detail="Standard nicht gefunden") diff --git a/backend/app/routers/subUseCases.py b/backend/app/routers/subUseCases.py index db9adae..605093f 100644 --- a/backend/app/routers/subUseCases.py +++ b/backend/app/routers/subUseCases.py @@ -5,7 +5,7 @@ from sqlalchemy.orm import Session from xml.etree import ElementTree as ET from ..models.users import User -from ..crud.auth import get_current_user_from_token, require_role, RoleEnum +from ..crud.auth import require_role, RoleEnum from ..db import get_db from ..crud import subUseCases as crud from ..schemas.subUseCases import * @@ -40,12 +40,12 @@ def list_subUseCases_by_role(role_id: int, db: Session = Depends(get_db)): # --------CREATE-------- @router.post("/", response_model=SubUseCase) -def create_subUseCase(data: SubUseCaseCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user_from_token)): +def create_subUseCase(data: SubUseCaseCreate, db: Session = Depends(get_db), current_user: User = Depends(require_role(RoleEnum.WRITE))): return crud.create_subUseCase(db, data, current_user.username) # --------UPDATE-------- @router.put("/{id}", response_model=SubUseCase) -def update_subUseCase(id: int, data: SubUseCaseUpdate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user_from_token)): +def update_subUseCase(id: int, data: SubUseCaseUpdate, db: Session = Depends(get_db), current_user: User = Depends(require_role(RoleEnum.WRITE))): subUseCase = crud.get_subUseCase_by_id(db, id) if not subUseCase: raise HTTPException(status_code=404, detail="Sub Use Case nicht gefunden") @@ -53,7 +53,7 @@ def update_subUseCase(id: int, data: SubUseCaseUpdate, db: Session = Depends(get # ---------DELETE-------- @router.delete("/{id}", status_code=204) -def delete_subUseCase(id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user_from_token)): +def delete_subUseCase(id: int, db: Session = Depends(get_db), current_user: User = Depends(require_role(RoleEnum.WRITE))): subUseCase = crud.get_subUseCase_by_id(db, id) if not subUseCase: raise HTTPException(status_code=404, detail="Sub Use Case nicht gefunden") diff --git a/backend/app/routers/transactions.py b/backend/app/routers/transactions.py index 731f264..54b0566 100644 --- a/backend/app/routers/transactions.py +++ b/backend/app/routers/transactions.py @@ -1,6 +1,6 @@ from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.orm import Session -from ..crud.auth import get_current_user_from_token, require_role, RoleEnum +from ..crud.auth import require_role, RoleEnum from ..db import get_db from ..crud import transactions as crud from ..models.users import User @@ -35,12 +35,12 @@ def list_transactions_by_subusecase(sub_id: int, db: Session = Depends(get_db)): # --------CREATE------- @router.post("/", response_model=Transaction) -def create_transaction(data: TransactionMutate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user_from_token)): +def create_transaction(data: TransactionMutate, db: Session = Depends(get_db), current_user: User = Depends(require_role(RoleEnum.WRITE))): return crud.create_transaction(db, data, current_user.username) # --------UPDATE------- @router.put("/{id}", response_model=Transaction) -def update_transaction(id: int, data: TransactionMutate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user_from_token)): +def update_transaction(id: int, data: TransactionMutate, db: Session = Depends(get_db), current_user: User = Depends(require_role(RoleEnum.WRITE))): transaction = crud.get_transaction_by_id(db, id) if not transaction: raise HTTPException(status_code=404, detail="Transaktion nicht gefunden") @@ -48,7 +48,7 @@ def update_transaction(id: int, data: TransactionMutate, db: Session = Depends(g # --------DELETE------- @router.delete("/{id}", status_code=204) -def delete_transaction(id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user_from_token)): +def delete_transaction(id: int, db: Session = Depends(get_db), current_user: User = Depends(require_role(RoleEnum.WRITE))): transaction = crud.get_transaction_by_id(db, id) if not transaction: raise HTTPException(status_code=404, detail="Transaktion nicht gefunden") diff --git a/backend/app/routers/useCases.py b/backend/app/routers/useCases.py index 54180c2..bfc3039 100644 --- a/backend/app/routers/useCases.py +++ b/backend/app/routers/useCases.py @@ -6,7 +6,7 @@ from ..models.users import User from ..schemas.useCases import * from ..schemas.logs import * -from ..crud.auth import require_role, RoleEnum, get_current_user_from_token +from ..crud.auth import require_role, RoleEnum router = APIRouter(prefix="/usecases", tags=["Use Cases"], dependencies=[Depends(require_role(RoleEnum.READ))]) @@ -32,7 +32,7 @@ def get_useCase_version(id: int, db: Session = Depends(get_db)): # ---------CREATE-------- @router.post("/", response_model=UseCase) -def create_useCase(data: UseCaseCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user_from_token)): +def create_useCase(data: UseCaseCreate, db: Session = Depends(get_db), current_user: User = Depends(require_role(RoleEnum.WRITE))): roles = db.query(models.Role).filter(models.Role.id.in_(data.roles)).all() if len(roles) != len(data.roles): raise HTTPException(status_code=400, detail="Eine oder mehrere Rollen nicht gefunden") @@ -40,7 +40,7 @@ def create_useCase(data: UseCaseCreate, db: Session = Depends(get_db), current_u # ---------UPDATE-------- @router.put("/{id}", response_model=UseCase) -def update_useCase(id: int, data: UseCaseUpdate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user_from_token)): +def update_useCase(id: int, data: UseCaseUpdate, db: Session = Depends(get_db), current_user: User = Depends(require_role(RoleEnum.WRITE))): roles = db.query(models.Role).filter(models.Role.id.in_(data.roles)).all() if len(roles) != len(data.roles): raise HTTPException(status_code=400, detail="Eine oder mehrere Rollen nicht gefunden") @@ -54,7 +54,7 @@ def update_useCase(id: int, data: UseCaseUpdate, db: Session = Depends(get_db), # ---------DELETE-------- @router.delete("/{id}", status_code=204) -def delete_useCase(id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user_from_token)): +def delete_useCase(id: int, db: Session = Depends(get_db), current_user: User = Depends(require_role(RoleEnum.WRITE))): useCase = crud.get_useCase_by_id(db, id) if not useCase: raise HTTPException(status_code=404, detail="Use Case nicht gefunden") diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0848509..8dbe129 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -14,6 +14,7 @@ "bpmn-js": "^18.10.1", "dagre": "^0.8.5", "lucide-react": "^0.562.0", + "n3": "^1.1.0", "react": "^19.2.0", "react-dom": "^19.2.3", "react-router-dom": "^7.9.6", @@ -21,6 +22,7 @@ }, "devDependencies": { "@eslint/js": "^9.39.1", + "@types/n3": "^1.16.4", "@types/node": "^24.10.1", "@types/react": "^19.2.5", "@types/react-dom": "^19.2.3", @@ -1143,6 +1145,15 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@rdfjs/types": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@rdfjs/types/-/types-2.0.1.tgz", + "integrity": "sha512-uyAzpugX7KekAXAHq26m3JlUIZJOC0uSBhpnefGV5i15bevDyyejoB7I+9MKeUrzXD8OOUI3+4FeV1wwQr5ihA==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.53", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz", @@ -1838,6 +1849,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/n3": { + "version": "1.26.6", + "resolved": "https://registry.npmjs.org/@types/n3/-/n3-1.26.6.tgz", + "integrity": "sha512-BLXT2zu5wcbIotEVJsCI/eUjV5QWbwNBPNwnDrEf3qFv844UiCGe8Pg/PKSBR572V2BtBFlu46pDLtvhsOeWqg==", + "dev": true, + "dependencies": { + "@rdfjs/types": "*", + "@types/node": "*" + } + }, "node_modules/@types/node": { "version": "24.10.4", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.4.tgz", @@ -2202,6 +2223,17 @@ "d3-zoom": "^3.0.0" } }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -2488,6 +2520,25 @@ "dev": true, "license": "MIT" }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, "node_modules/baseline-browser-mapping": { "version": "2.9.11", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.11.tgz", @@ -2574,6 +2625,29 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, "node_modules/call-bind": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", @@ -3630,6 +3704,22 @@ "node": ">=0.10.0" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "engines": { + "node": ">=6" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "engines": { + "node": ">=0.8.x" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -4088,6 +4178,25 @@ "resolved": "https://registry.npmjs.org/ids/-/ids-1.0.5.tgz", "integrity": "sha512-XQ0yom/4KWTL29sLG+tyuycy7UmeaM/79GRtSJq6IG9cJGIPeBz5kwDCguie3TwxaMNIc3WtPi0cTa1XYHicpw==" }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -5104,6 +5213,18 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/n3": { + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/n3/-/n3-1.26.0.tgz", + "integrity": "sha512-SQknS0ua90rN+3RHuk8BeIqeYyqIH/+ecViZxX08jR4j6MugqWRjtONl3uANG/crWXnOM2WIqBJtjIhVYFha+w==", + "dependencies": { + "buffer": "^6.0.3", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">=12.0" + } + }, "node_modules/nanoid": { "version": "3.3.16", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", @@ -5494,6 +5615,14 @@ "node": ">= 0.8.0" } }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -5627,6 +5756,21 @@ "react-dom": ">=16.6.0" } }, + "node_modules/readable-stream": { + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.8.0.tgz", + "integrity": "sha512-2jhTTU3Eilp/2DMT71gbcoqC35eQHasMJH0RHkYxwlxIv3+FR6Y5xAFZIwEGLxKkjZYEFlsiMNIdwUqjK2lLHQ==", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -5764,6 +5908,25 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, "node_modules/safe-push-apply": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", @@ -6009,6 +6172,14 @@ "node": ">= 0.4" } }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string.prototype.matchall": { "version": "4.0.12", "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", diff --git a/frontend/package.json b/frontend/package.json index 4fa0e54..91b4c36 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -18,6 +18,7 @@ "bpmn-js": "^18.10.1", "dagre": "^0.8.5", "lucide-react": "^0.562.0", + "n3": "^1.1.0", "react": "^19.2.0", "react-dom": "^19.2.3", "react-router-dom": "^7.9.6", @@ -27,6 +28,7 @@ "@eslint/js": "^9.39.1", "@types/node": "^24.10.1", "@types/react": "^19.2.5", + "@types/n3": "^1.16.4", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.1", "autoprefixer": "^10.4.22", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index bc196a1..bc1bf80 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -109,6 +109,7 @@ function AppContent() { +
@@ -132,4 +133,4 @@ function AppContent() { ); } -export default AppContent; \ No newline at end of file +export default AppContent; diff --git a/frontend/src/components/CrudTable.tsx b/frontend/src/components/CrudTable.tsx index f86b060..d5e52c5 100644 --- a/frontend/src/components/CrudTable.tsx +++ b/frontend/src/components/CrudTable.tsx @@ -1,4 +1,5 @@ import React, {useState, useMemo} from "react"; +import { useAuth } from "../auth/useAuth.ts"; import { Link } from "react-router-dom"; import { ChevronUp, ChevronDown, Search, Trash2, Edit2, Plus, Inbox } from "lucide-react"; import type {MultiLangString} from "../types.ts"; @@ -30,6 +31,7 @@ function resolveValue(value: unknown): string | number { if (value === null || value === undefined) return ""; if (typeof value === "number") return value; if (typeof value === "string") return value; + if (Array.isArray(value)) return value.join(' '); if (typeof value === "object") { const obj = value as Record; @@ -63,6 +65,7 @@ export default function CrudTable({ isLoading = false, }: Props) { const [sortKey, setSortKey] = useState(null); + const { hasWriteAccess } = useAuth(); const [sortDirection, setSortDirection] = useState("asc"); const [filterText, setFilterText] = useState(""); @@ -147,7 +150,8 @@ export default function CrudTable({ diff --git a/frontend/src/components/ttlModel.ts b/frontend/src/components/ttlModel.ts new file mode 100644 index 0000000..54f2ca7 --- /dev/null +++ b/frontend/src/components/ttlModel.ts @@ -0,0 +1,153 @@ +import { Parser, type Quad, type Term } from "n3"; + +const RDF_TYPE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type"; +const XSD_STRING = "http://www.w3.org/2001/XMLSchema#string"; +const LABEL_PREDICATES = new Set([ + "http://www.w3.org/2000/01/rdf-schema#label", + "http://www.w3.org/2004/02/skos/core#prefLabel", + "http://purl.org/dc/terms/title", +]); + +export interface ObjectValue { + kind: "resource" | "literal"; + value: string; + lang?: string; + datatype?: string; +} +export interface PredicateEntry { + predicate: string; + objects: ObjectValue[]; +} +export interface Entity { + id: string; + types: string[]; + label?: string; + predicates: PredicateEntry[]; + search: string; +} +export interface TtlModel { + entities: Map; + roots: string[]; + tripleCount: number; + shorten: (iri: string) => string; +} + +const idOf = (t: Term) => (t.termType === "BlankNode" ? `_:${t.value}` : t.value); +const isResource = (t: Term) => t.termType === "NamedNode" || t.termType === "BlankNode"; + +export function parseTurtle(text: string): TtlModel { + const prefixes: Record = {}; + const parser = new Parser({ format: "text/turtle" }); + const quads: Quad[] = parser.parse(text, null, (prefix: string, node) => { + prefixes[prefix] = node.value; + }); + return buildModel(quads, prefixes); +} + + +function buildModel(quads: Quad[], prefixes: Record): TtlModel { + // Präfixe: längster Namespace zuerst + const namespaces = Object.entries(prefixes) + .map(([p, v]) => [p, typeof v === "string" ? v : (v as { value: string }).value] as const) + .sort((a, b) => b[1].length - a[1].length); + const shorten = (iri: string) => { + for (const [p, ns] of namespaces) { + if (ns && iri.startsWith(ns)) return `${p}:${iri.slice(ns.length)}`; + } + return iri; + }; + + const entities = new Map(); + const labels = new Map(); + const referenced = new Set(); + + const ensure = (id: string) => { + let e = entities.get(id); + if (!e) { + e = { id, types: [], predicates: [], search: "" }; + entities.set(id, e); + } + return e; + }; + + for (const q of quads) { + if (!isResource(q.subject)) continue; + const s = ensure(idOf(q.subject)); + const p = q.predicate.value; + const o = q.object; + + if (p === RDF_TYPE && isResource(o)) { + s.types.push(idOf(o)); + continue; + } + + let value: ObjectValue; + if (o.termType === "Literal") { + value = { + kind: "literal", + value: o.value, + lang: o.language || undefined, + datatype: !o.language && o.datatype.value !== XSD_STRING ? o.datatype.value : undefined, + }; + if (LABEL_PREDICATES.has(p)) { + const list = labels.get(s.id) ?? []; + list.push({ value: o.value, lang: o.language }); + labels.set(s.id, list); + } + } else if (isResource(o)) { + value = { kind: "resource", value: idOf(o) }; + referenced.add(value.value); + } else { + continue; + } + + let entry = s.predicates.find((x) => x.predicate === p); + if (!entry) { + entry = { predicate: p, objects: [] }; + s.predicates.push(entry); + } + entry.objects.push(value); + } + + // Label wählen (de > en > ohne Sprache > andere) und Suchtext bauen + const rank = (lang: string) => (lang === "de" ? 0 : lang === "en" ? 1 : lang === "" ? 2 : 3); + for (const e of entities.values()) { + const cand = labels.get(e.id); + if (cand?.length) e.label = [...cand].sort((a, b) => rank(a.lang) - rank(b.lang))[0].value; + const lits = e.predicates.flatMap((p) => + p.objects.filter((o) => o.kind === "literal").map((o) => o.value) + ); + e.search = [e.id, e.label ?? "", ...e.types, ...lits].join(" ").toLowerCase(); + } + + // Wurzeln: Subjekte, auf die nichts verweist. Rest (reine Zyklen) wird ergänzt. + const roots = [...entities.keys()].filter((id) => !referenced.has(id)); + // Ensure that class definitions (owl:Class) are included as roots even if they are referenced elsewhere + // This guarantees that ontology class entities are displayed in the viewer. + for (const [id, entity] of entities.entries()) { + if (entity.types.includes('http://www.w3.org/2002/07/owl#Class') && !roots.includes(id)) { + roots.push(id); + } + } + const reachable = new Set(); + const visit = (start: string) => { + const stack = [start]; + while (stack.length) { + const id = stack.pop()!; + if (reachable.has(id)) continue; + reachable.add(id); + for (const p of entities.get(id)?.predicates ?? []) + for (const o of p.objects) if (o.kind === "resource") stack.push(o.value); + } + }; + roots.forEach(visit); + for (const id of entities.keys()) { + if (!reachable.has(id)) { + roots.push(id); + visit(id); + } + } + + return { entities, roots, tripleCount: quads.length, shorten }; + +} diff --git a/frontend/src/pages/ClassDetail.tsx b/frontend/src/pages/ClassDetail.tsx index a82aa6b..3e02f74 100644 --- a/frontend/src/pages/ClassDetail.tsx +++ b/frontend/src/pages/ClassDetail.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from "react"; +import { useAuth } from "../auth/useAuth.ts"; import { useParams, useNavigate } from "react-router-dom"; import {api, ApiError} from "../api/api.ts"; import type {PropertyGroup, Property, VersionConflictDetail} from "../types.ts"; @@ -23,6 +24,7 @@ const emptyClass: Partial = { }; export default function ClassDetail() { + const { hasWriteAccess } = useAuth(); const { uuid } = useParams(); const navigate = useNavigate(); const [item, setItem] = useState>(emptyClass); @@ -131,6 +133,16 @@ export default function ClassDetail() {
Klasse
+ {/* Download AASX button */} + {uuid !== "new" && ( + + )} diff --git a/frontend/src/pages/PropertyDetail.tsx b/frontend/src/pages/PropertyDetail.tsx index 59f5a26..37bd02f 100644 --- a/frontend/src/pages/PropertyDetail.tsx +++ b/frontend/src/pages/PropertyDetail.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from "react"; +import { useAuth } from "../auth/useAuth.ts"; import { useParams, useNavigate } from "react-router-dom"; import {api, ApiError} from "../api/api.ts"; import type { Property, PropertyGroup, VersionConflictDetail } from "../types.ts"; @@ -20,6 +21,7 @@ const emptyProperty: Partial = { }; export default function PropertyDetail() { + const { hasWriteAccess } = useAuth(); const { uuid } = useParams(); const navigate = useNavigate(); const [item, setItem] = useState>(emptyProperty); @@ -584,8 +586,8 @@ export default function PropertyDetail() { {/* Aktionen */}
- + {open && ( +
{children}
+ )} +
+ ); +} + +function Badge({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + +function ObjectRow({ + o, + model, + path, +}: { + o: ObjectValue; + model: TtlModel; + path: ReadonlySet; +}) { + if (o.kind === "literal") { + return ( +
+ {o.value} + {o.lang && @{o.lang}} + {o.datatype && ( + ^^{model.shorten(o.datatype)} + )} +
+ ); + } + const target = model.entities.get(o.value); + const name = target?.label ?? model.shorten(o.value); + if (!target) return
{name}
; + if (path.has(o.value)) { + return ( +
+ ↺ {name} (Zyklus) +
+ ); + } + return ; +} + +function EntityNode({ + id, + model, + ancestors, + defaultOpen = false, +}: { + id: string; + model: TtlModel; + ancestors: ReadonlySet; + defaultOpen?: boolean; +}) { + const entity = model.entities.get(id)!; + const path = useMemo(() => new Set(ancestors).add(id), [ancestors, id]); + + return ( + + {entity.label ?? model.shorten(id)} + {entity.label && ( + {model.shorten(id)} + )} + {entity.types.map((t) => ( + {model.shorten(t)} + ))} + + } + > + {entity.predicates.length === 0 && ( +
(keine weiteren Eigenschaften)
+ )} + {entity.predicates.map((p) => ( +
+
+ {model.shorten(p.predicate)} +
+
+ {p.objects.map((o, i) => ( + + ))} +
+
+ ))} +
+ ); +} + +const EMPTY: ReadonlySet = new Set(); + +export default function TurtleViewer() { + const [model, setModel] = useState(null); + const [fileName, setFileName] = useState(""); + const [error, setError] = useState(null); + const [query, setQuery] = useState(""); + const [grouped, setGrouped] = useState(true); + + const onFile = async (e: ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + try { + setModel(await parseTurtle(await file.text())); + setFileName(file.name); + setQuery(""); + setError(null); + } catch (err) { + setModel(null); + setError(err instanceof Error ? err.message : "Parsing fehlgeschlagen"); + } + e.target.value = ""; // gleiche Datei erneut wählbar + }; + + const searching = query.trim() !== ""; + + const ids = useMemo(() => { + if (!model) return []; + if (!searching) return model.roots; + const q = query.trim().toLowerCase(); + return [...model.entities.values()] + .filter((e) => e.search.includes(q)) + .slice(0, 200) + .map((e) => e.id); + }, [model, query, searching]); + + const groups = useMemo(() => { + if (!model) return []; + if (!grouped || searching) return [{ key: "", ids }]; + const map = new Map(); + for (const id of ids) { + const key = model.entities.get(id)!.types[0] ?? ""; + map.set(key, [...(map.get(key) ?? []), id]); + } + return [...map.entries()] + .sort(([a], [b]) => (a === "" ? 1 : b === "" ? -1 : a.localeCompare(b))) + .map(([key, ids]) => ({ key, ids })); + }, [model, ids, grouped, searching]); + + const renderIds = (list: string[]) => + list.map((id) => ); + + return ( +
+
+ + {model && ( + <> + setQuery(e.target.value)} + /> + + + )} +
+ + {error &&

{error}

} + + {model && ( + <> +

+ {fileName} · {model.tripleCount} Tripel · {model.entities.size} Ressourcen + {searching && ` · ${ids.length} Treffer`} +

+ + {groups.map((g) => + g.key === "" && groups.length === 1 ? ( +
{renderIds(g.ids)}
+ ) : ( + + {g.key ? model.shorten(g.key) : "Ohne Typ"}{" "} + ({g.ids.length}) + + } + > + {renderIds(g.ids)} + + ) + )} + + )} +
+ ); +} diff --git a/frontend/src/pages/UseCaseDetail.tsx b/frontend/src/pages/UseCaseDetail.tsx index 8b45e55..8a4a624 100644 --- a/frontend/src/pages/UseCaseDetail.tsx +++ b/frontend/src/pages/UseCaseDetail.tsx @@ -1,4 +1,5 @@ import {useEffect, useState} from "react"; +import { useAuth } from "../auth/useAuth.ts"; import { useParams, useNavigate } from "react-router-dom"; import { api, ApiError } from "../api/api.ts"; import type {UseCase, Role, VersionConflictDetail} from "../types.ts"; @@ -24,6 +25,7 @@ const emptyUseCase: UseCase = { } export default function UseCaseDetail() { + const { hasWriteAccess } = useAuth(); const { id } = useParams(); const navigate = useNavigate(); const [item, setItem] = useState(emptyUseCase) @@ -423,8 +425,8 @@ export default function UseCaseDetail() { {/* Aktionen */}
-