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`.