diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index bab6cd4f..80ab5da6 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -3,22 +3,18 @@ name: Build and Push Docker Image on: workflow_dispatch: inputs: - tag_name: - description: "Custom tag name for the Docker image" - required: false - default: "" - registry: - description: "Docker registry to push to" + version: + description: "Version to build (e.g., 1.8.0)" required: true - default: "ghcr" - type: choice - options: - - "ghcr" - - "dockerhub" + production: + description: "Is this a production build?" + required: true + default: false + type: boolean jobs: build: - runs-on: blacksmith-4vcpu-ubuntu-2404 + runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@v5 @@ -28,102 +24,70 @@ jobs: - name: Set up QEMU uses: docker/setup-qemu-action@v3 with: - platforms: arm64 + platforms: linux/amd64,linux/arm64,linux/arm/v7 - - name: Setup Blacksmith Builder - uses: useblacksmith/setup-docker-builder@v1 + - name: Setup Docker Buildx + uses: docker/setup-buildx-action@v3 - - name: Cache npm dependencies - uses: actions/cache@v4 - with: - path: | - ~/.npm - node_modules - */*/node_modules - key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} - restore-keys: | - ${{ runner.os }}-node- + - name: Determine tags + id: tags + run: | + VERSION=${{ github.event.inputs.version }} + PROD=${{ github.event.inputs.production }} - - name: Cache Docker layers - uses: actions/cache@v4 - with: - path: /tmp/.buildx-cache - key: ${{ runner.os }}-buildx-${{ github.ref_name }}-${{ hashFiles('docker/Dockerfile') }} - restore-keys: | - ${{ runner.os }}-buildx-${{ github.ref_name }}- - ${{ runner.os }}-buildx- + TAGS=() + ALL_TAGS=() - - name: Login to GitHub Container Registry - if: github.event.inputs.registry != 'dockerhub' + if [ "$PROD" = "true" ]; then + # Production build → push release + latest to both GHCR and Docker Hub + TAGS+=("release-$VERSION" "latest") + for tag in "${TAGS[@]}"; do + ALL_TAGS+=("ghcr.io/lukegus/termix:$tag") + ALL_TAGS+=("docker.io/bugattiguy527/termix:$tag") + done + else + # Dev build → push only dev-x.x.x to GHCR + TAGS+=("dev-$VERSION") + for tag in "${TAGS[@]}"; do + ALL_TAGS+=("ghcr.io/lukegus/termix:$tag") + done + fi + + echo "ALL_TAGS=${ALL_TAGS[*]}" >> $GITHUB_ENV + echo "All tags to build:" + printf '%s\n' "${ALL_TAGS[@]}" + + - name: Login to GHCR uses: docker/login-action@v3 with: registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} + username: lukegus + password: ${{ secrets.GHCR_TOKEN }} - - name: Login to Docker Hub - if: github.event.inputs.registry == 'dockerhub' + - name: Login to Docker Hub (prod only) + if: ${{ github.event.inputs.production == 'true' }} uses: docker/login-action@v3 with: username: bugattiguy527 password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Determine Docker image tag - run: | - REPO_OWNER=$(echo ${{ github.repository_owner }} | tr '[:upper:]' '[:lower:]') - echo "REPO_OWNER=$REPO_OWNER" >> $GITHUB_ENV - - if [ "${{ github.event.inputs.tag_name }}" != "" ]; then - IMAGE_TAG="${{ github.event.inputs.tag_name }}" - elif [ "${{ github.ref }}" == "refs/heads/main" ]; then - IMAGE_TAG="latest" - elif [ "${{ github.ref }}" == "refs/heads/development" ]; then - IMAGE_TAG="development-latest" - else - IMAGE_TAG="${{ github.ref_name }}" - fi - echo "IMAGE_TAG=$IMAGE_TAG" >> $GITHUB_ENV - - # Determine registry and image name - if [ "${{ github.event.inputs.registry }}" == "dockerhub" ]; then - echo "REGISTRY=docker.io" >> $GITHUB_ENV - echo "IMAGE_NAME=bugattiguy527/termix" >> $GITHUB_ENV - else - echo "REGISTRY=ghcr.io" >> $GITHUB_ENV - echo "IMAGE_NAME=$REPO_OWNER/termix" >> $GITHUB_ENV - fi - - - name: Build and Push Multi-Arch Docker Image - uses: useblacksmith/build-push-action@v2 + - name: Build and push multi-arch image + uses: docker/build-push-action@v5 with: context: . file: ./docker/Dockerfile push: true - platforms: linux/amd64,linux/arm64 - tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ env.IMAGE_TAG }} - labels: | - org.opencontainers.image.source=https://github.com/${{ github.repository }} - org.opencontainers.image.revision=${{ github.sha }} + platforms: linux/amd64,linux/arm64,linux/arm/v7 + tags: ${{ env.ALL_TAGS }} build-args: | BUILDKIT_INLINE_CACHE=1 BUILDKIT_CONTEXT_KEEP_GIT_DIR=1 + labels: | + org.opencontainers.image.source=https://github.com/${{ github.repository }} + org.opencontainers.image.revision=${{ github.sha }} outputs: type=registry,compression=zstd,compression-level=19 - - name: Move cache - run: | - rm -rf /tmp/.buildx-cache - mv /tmp/.buildx-cache-new /tmp/.buildx-cache - - - name: Delete all untagged image versions - if: success() && github.event.inputs.registry != 'dockerhub' - uses: quartx-analytics/ghcr-cleaner@v1 - with: - owner-type: user - token: ${{ secrets.GHCR_TOKEN }} - repository-owner: ${{ github.repository_owner }} - delete-untagged: true - - - name: Cleanup Docker Images Locally + - name: Cleanup Docker if: always() run: | docker image prune -af diff --git a/docker/Dockerfile b/docker/Dockerfile index 65fdce1b..555114e6 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -82,7 +82,7 @@ COPY --chown=node:node package.json ./ VOLUME ["/app/data"] -EXPOSE ${PORT} 30001 30002 30003 30004 30005 300006 +EXPOSE ${PORT} 30001 30002 30003 30004 30005 30006 COPY docker/entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh diff --git a/src/backend/database/database.ts b/src/backend/database/database.ts index d6c95cd5..cef1130c 100644 --- a/src/backend/database/database.ts +++ b/src/backend/database/database.ts @@ -901,17 +901,40 @@ app.post( const userId = (req as AuthenticatedRequest).userId; const { password } = req.body; + const mainDb = getDb(); - if (!password) { - return res.status(400).json({ - error: "Password required for import", - code: "PASSWORD_REQUIRED", - }); + const userRecords = await mainDb + .select() + .from(users) + .where(eq(users.id, userId)); + + if (!userRecords || userRecords.length === 0) { + return res.status(404).json({ error: "User not found" }); } - const unlocked = await authManager.authenticateUser(userId, password); - if (!unlocked) { - return res.status(401).json({ error: "Invalid password" }); + const isOidcUser = !!userRecords[0].is_oidc; + + if (!isOidcUser) { + // Local accounts still prove knowledge of the password so their DEK can be derived again. + if (!password) { + return res.status(400).json({ + error: "Password required for import", + code: "PASSWORD_REQUIRED", + }); + } + + const unlocked = await authManager.authenticateUser(userId, password); + if (!unlocked) { + return res.status(401).json({ error: "Invalid password" }); + } + } else if (!DataCrypto.getUserDataKey(userId)) { + // OIDC users skip the password prompt; make sure their DEK is unlocked via the OIDC session. + const oidcUnlocked = await authManager.authenticateOIDCUser(userId); + if (!oidcUnlocked) { + return res.status(403).json({ + error: "Failed to unlock user data with SSO credentials", + }); + } } apiLogger.info("Importing SQLite data", { @@ -922,7 +945,14 @@ app.post( mimetype: req.file.mimetype, }); - const userDataKey = DataCrypto.getUserDataKey(userId); + let userDataKey = DataCrypto.getUserDataKey(userId); + if (!userDataKey && isOidcUser) { + // authenticateOIDCUser lazily provisions the session key; retry the fetch when it succeeds. + const oidcUnlocked = await authManager.authenticateOIDCUser(userId); + if (oidcUnlocked) { + userDataKey = DataCrypto.getUserDataKey(userId); + } + } if (!userDataKey) { throw new Error("User data not unlocked"); } @@ -976,7 +1006,6 @@ app.post( }; try { - const mainDb = getDb(); try { const importedHosts = importDb diff --git a/src/backend/utils/auth-manager.ts b/src/backend/utils/auth-manager.ts index e560d845..5a1b3263 100644 --- a/src/backend/utils/auth-manager.ts +++ b/src/backend/utils/auth-manager.ts @@ -239,12 +239,19 @@ class AuthManager { createAdminMiddleware() { return async (req: Request, res: Response, next: NextFunction) => { - const authHeader = req.headers["authorization"]; - if (!authHeader?.startsWith("Bearer ")) { - return res.status(401).json({ error: "Missing Authorization header" }); + let token = req.cookies?.jwt; + + if (!token) { + const authHeader = req.headers["authorization"]; + if (authHeader?.startsWith("Bearer ")) { + token = authHeader.split(" ")[1]; + } + } + + if (!token) { + return res.status(401).json({ error: "Missing authentication token" }); } - const token = authHeader.split(" ")[1]; const payload = await this.verifyJWTToken(token); if (!payload) { diff --git a/src/ui/Desktop/Admin/AdminSettings.tsx b/src/ui/Desktop/Admin/AdminSettings.tsx index 0d829895..6d0ff0f8 100644 --- a/src/ui/Desktop/Admin/AdminSettings.tsx +++ b/src/ui/Desktop/Admin/AdminSettings.tsx @@ -45,6 +45,8 @@ import { makeUserAdmin, removeAdminStatus, deleteUser, + getUserInfo, + getCookie, isElectron, } from "@/ui/main-axios.ts"; @@ -94,6 +96,14 @@ export function AdminSettings({ null, ); + const [securityInitialized, setSecurityInitialized] = React.useState(true); + const [currentUser, setCurrentUser] = React.useState<{ + id: string; + username: string; + is_admin: boolean; + is_oidc: boolean; + } | null>(null); + const [exportLoading, setExportLoading] = React.useState(false); const [importLoading, setImportLoading] = React.useState(false); const [importFile, setImportFile] = React.useState(null); @@ -101,6 +111,11 @@ export function AdminSettings({ const [showPasswordInput, setShowPasswordInput] = React.useState(false); const [importPassword, setImportPassword] = React.useState(""); + const requiresImportPassword = React.useMemo( + () => !currentUser?.is_oidc, + [currentUser?.is_oidc], + ); + React.useEffect(() => { if (isElectron()) { const serverUrl = (window as { configuredServerUrl?: string }) @@ -119,6 +134,23 @@ export function AdminSettings({ toast.error(t("admin.failedToFetchOidcConfig")); } }); + // Capture the current session so we know whether to ask for a password later. + getUserInfo() + .then((info) => { + if (info) { + setCurrentUser({ + id: info.userId, + username: info.username, + is_admin: info.is_admin, + is_oidc: info.is_oidc, + }); + } + }) + .catch((err) => { + if (!err?.message?.includes("No server configured")) { + console.warn("Failed to fetch current user info", err); + } + }); fetchUsers(); }, []); @@ -372,7 +404,7 @@ export function AdminSettings({ return; } - if (!importPassword.trim()) { + if (requiresImportPassword && !importPassword.trim()) { toast.error(t("admin.passwordRequired")); return; } @@ -395,7 +427,10 @@ export function AdminSettings({ const formData = new FormData(); formData.append("file", importFile); - formData.append("password", importPassword); + if (requiresImportPassword) { + // Preserve the existing password flow for non-OIDC accounts. + formData.append("password", importPassword); + } const response = await fetch(apiUrl, { method: "POST", @@ -1016,7 +1051,8 @@ export function AdminSettings({ - {importFile && ( + {/* Only render the password field when a local account is performing the import. */} + {importFile && requiresImportPassword && (