diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 5cbb574..fc3b56f 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -190,6 +190,12 @@ deploy_to_vps: - eval $(ssh-agent -s) - | key_file="$(mktemp)" + decoded_key_file="$(mktemp)" + + cleanup_key_files() { + rm -f "$key_file" "$decoded_key_file" + } + trap cleanup_key_files EXIT if [ -f "${SSH_PRIVATE_KEY:-}" ]; then tr -d '\r' < "$SSH_PRIVATE_KEY" > "$key_file" @@ -198,13 +204,22 @@ deploy_to_vps: fi chmod 600 "$key_file" + + if ! ssh-keygen -y -f "$key_file" >/dev/null 2>&1; then + if [ ! -f "${SSH_PRIVATE_KEY:-}" ] && printf '%s' "$SSH_PRIVATE_KEY" | tr -d '\r\n\t ' | base64 -d > "$decoded_key_file" 2>/dev/null; then + tr -d '\r' < "$decoded_key_file" > "$key_file" + chmod 600 "$key_file" + fi + fi + ssh-keygen -y -f "$key_file" >/dev/null 2>&1 || { - echo "SSH_PRIVATE_KEY is not a valid unencrypted private key that OpenSSH can read." >&2 + echo "SSH_PRIVATE_KEY must be an unencrypted OpenSSH private key. GitLab file variables, raw multiline keys, and base64-encoded keys are supported." >&2 exit 1 } ssh-add "$key_file" - rm -f "$key_file" + cleanup_key_files + trap - EXIT - mkdir -p ~/.ssh && chmod 700 ~/.ssh # Scan and trust the VPS host key (avoids manual known_hosts management) - ssh-keyscan -H $VPS_IP >> ~/.ssh/known_hosts diff --git a/apps/dashboard/src/app/(dashboard)/layout.tsx b/apps/dashboard/src/app/(dashboard)/layout.tsx index 59cb7cd..bdfb519 100644 --- a/apps/dashboard/src/app/(dashboard)/layout.tsx +++ b/apps/dashboard/src/app/(dashboard)/layout.tsx @@ -1,3 +1,4 @@ +import DashboardAccessGuard from '@/components/layout/DashboardAccessGuard' import Sidebar from '@/components/layout/Sidebar' import TopBar from '@/components/layout/TopBar' @@ -11,7 +12,9 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
-
{children}
+
+ {children} +
) diff --git a/apps/dashboard/src/components/layout/DashboardAccessGuard.tsx b/apps/dashboard/src/components/layout/DashboardAccessGuard.tsx new file mode 100644 index 0000000..cefebca --- /dev/null +++ b/apps/dashboard/src/components/layout/DashboardAccessGuard.tsx @@ -0,0 +1,93 @@ +'use client' + +import { usePathname, useRouter } from 'next/navigation' +import { useEffect, useState } from 'react' +import { apiFetch } from '@/lib/api' +import { toDashboardAppPath, toPublicDashboardPath } from '@/lib/dashboardPaths' + +type GeneratedMenuItem = { + id: string + itemType: 'INTERNAL_PAGE' | 'EXTERNAL_LINK' | 'PARENT_MENU' | 'SECTION_LABEL' | 'DIVIDER' + routeOrUrl: string | null + children: GeneratedMenuItem[] +} + +type EmployeeMenuResponse = { + items: GeneratedMenuItem[] +} + +function flattenInternalRoutes(items: GeneratedMenuItem[]): string[] { + const routes: string[] = [] + + const walk = (entry: GeneratedMenuItem) => { + if (entry.itemType === 'INTERNAL_PAGE' && entry.routeOrUrl) { + routes.push(entry.routeOrUrl) + } + entry.children.forEach(walk) + } + + items.forEach(walk) + return routes +} + +function isAllowedRoute(currentPath: string, allowedRoutes: string[]) { + return allowedRoutes.some((route) => { + if (route === '/') return currentPath === '/' + return currentPath === route || currentPath.startsWith(`${route}/`) + }) +} + +export default function DashboardAccessGuard({ children }: { children: React.ReactNode }) { + const pathname = usePathname() + const router = useRouter() + const [ready, setReady] = useState(false) + + useEffect(() => { + let cancelled = false + + async function enforceAccess() { + try { + setReady(false) + const currentPath = toDashboardAppPath(pathname) + const menu = await apiFetch('/auth/employee/menu') + if (cancelled) return + + const allowedRoutes = flattenInternalRoutes(menu.items) + const fallbackRoute = allowedRoutes[0] ?? '/' + + if (!isAllowedRoute(currentPath, allowedRoutes)) { + router.replace(toPublicDashboardPath(fallbackRoute)) + return + } + + setReady(true) + } catch (error: any) { + if (cancelled) return + + if (error?.statusCode === 401 || error?.statusCode === 403 || error?.statusCode === 402) { + router.replace(toPublicDashboardPath('/')) + return + } + + // Do not deadlock the dashboard on transient network failures. + setReady(true) + } + } + + enforceAccess() + + return () => { + cancelled = true + } + }, [pathname, router]) + + if (!ready) { + return ( +
+
+
+ ) + } + + return <>{children} +} diff --git a/docs/DOCKER.md b/docs/DOCKER.md index e8c352d..dacce9f 100644 --- a/docs/DOCKER.md +++ b/docs/DOCKER.md @@ -200,6 +200,22 @@ REGISTRY_USER= REGISTRY_PASSWORD= ``` +For the deploy job, add these GitLab CI variables as well: + +```text +VPS_IP= +VPS_USER= +SSH_PRIVATE_KEY= +``` + +`SSH_PRIVATE_KEY` must be an unencrypted private key that OpenSSH can read in a non-interactive job. The pipeline accepts any of these formats: + +- GitLab `File` variable containing the private key +- Standard multiline key pasted directly into the variable value +- Base64-encoded private key stored as a single line + +If your key is passphrase-protected, generate a dedicated deploy key without a passphrase for CI instead of reusing an interactive workstation key. + The production compose file reads `APP_IMAGE` and `APP_VERSION` for pull-based deploys. The GitLab deploy job injects those values automatically and now syncs the deployment assets to the VPS before running the server-side deploy script. Production no longer depends on `git pull` during release. The CI pipeline publishes and deploys from the GitLab default branch (`$CI_DEFAULT_BRANCH`). If you change your release branch, update the repository default branch in GitLab instead of hard-coding branch names in `.gitlab-ci.yml`.