mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6be528cdc | ||
|
|
4d04b643c5 | ||
|
|
f5d3ce531a | ||
|
|
112fbfdef3 | ||
|
|
89d417fc0a | ||
|
|
b3106435e9 | ||
|
|
849d616451 | ||
|
|
a48356a2d2 | ||
|
|
f71b0247da |
@@ -0,0 +1,246 @@
|
||||
name: Auto SaaS Dev Deployment
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- saas-prod
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
FRONTEND_PORT: "901"
|
||||
BACKEND_PORT: "902"
|
||||
DEPLOY_DIR: /stirling/SAAS-DEV
|
||||
|
||||
jobs:
|
||||
deploy-saas-dev:
|
||||
runs-on: ubuntu-latest
|
||||
environment: saas-dev
|
||||
concurrency:
|
||||
group: saas-dev-deploy
|
||||
cancel-in-progress: true
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Check SaaS configuration
|
||||
id: config
|
||||
env:
|
||||
PROJECT_REF: ${{ secrets.SAAS_DB_PROJECT_REF }}
|
||||
run: |
|
||||
echo "supabase_url=https://${PROJECT_REF}.supabase.co" >> "$GITHUB_OUTPUT"
|
||||
echo "meter_endpoint=https://${PROJECT_REF}.supabase.co/functions/v1/meter-payg-units" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ github.token }}
|
||||
|
||||
- name: Convert repository owner to lowercase
|
||||
id: repoowner
|
||||
run: echo "lowercase=$(echo ${{ github.repository_owner }} | awk '{print tolower($0)}')" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Get commit hash
|
||||
id: commit-hash
|
||||
run: echo "app_short=$(git rev-parse --short=8 HEAD)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build and push backend image
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/backend/Dockerfile
|
||||
push: true
|
||||
cache-from: type=gha,scope=stirling-saas-backend
|
||||
cache-to: type=gha,mode=max,scope=stirling-saas-backend
|
||||
tags: |
|
||||
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:saas-backend-${{ steps.commit-hash.outputs.app_short }}
|
||||
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:saas-backend-latest
|
||||
build-args: |
|
||||
VERSION_TAG=v2-alpha
|
||||
STIRLING_FLAVOR=saas
|
||||
platforms: linux/amd64
|
||||
|
||||
- name: Build and push frontend image
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/frontend/Dockerfile
|
||||
push: true
|
||||
cache-from: type=gha,scope=stirling-saas-frontend
|
||||
cache-to: type=gha,mode=max,scope=stirling-saas-frontend
|
||||
tags: |
|
||||
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:saas-frontend-${{ steps.commit-hash.outputs.app_short }}
|
||||
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:saas-frontend-latest
|
||||
build-args: |
|
||||
VERSION_TAG=v2-alpha
|
||||
STIRLING_FLAVOR=saas
|
||||
VITE_BUILD_MODE=development
|
||||
VITE_SUPABASE_URL=${{ steps.config.outputs.supabase_url }}
|
||||
VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY=${{ secrets.SAAS_SUPABASE_PUBLISHABLE_KEY }}
|
||||
platforms: linux/amd64
|
||||
|
||||
- name: Build and push AI engine image
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
context: .
|
||||
file: ./engine/Dockerfile
|
||||
push: true
|
||||
cache-from: type=gha,scope=stirling-saas-engine
|
||||
cache-to: type=gha,mode=max,scope=stirling-saas-engine
|
||||
tags: |
|
||||
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:saas-engine-${{ steps.commit-hash.outputs.app_short }}
|
||||
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:saas-engine-latest
|
||||
platforms: linux/amd64
|
||||
|
||||
- name: Set up SSH
|
||||
env:
|
||||
SSH_KEY: ${{ secrets.NEW_VPS_SSH_KEY }}
|
||||
run: |
|
||||
mkdir -p ~/.ssh/
|
||||
echo "$SSH_KEY" > ../private.key
|
||||
sudo chmod 600 ../private.key
|
||||
|
||||
- name: Deploy to VPS
|
||||
env:
|
||||
IMAGE_BASE: ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test
|
||||
IMAGE_TAG: ${{ steps.commit-hash.outputs.app_short }}
|
||||
GHCR_USER: ${{ github.actor }}
|
||||
GHCR_TOKEN: ${{ github.token }}
|
||||
VPS_USERNAME: ${{ secrets.NEW_VPS_USERNAME }}
|
||||
VPS_HOST: ${{ secrets.NEW_VPS_HOST }}
|
||||
SAAS_DB_URL: ${{ secrets.SAAS_DB_URL }}
|
||||
SAAS_DB_USERNAME: ${{ secrets.SAAS_DB_USERNAME || 'postgres' }}
|
||||
SAAS_DB_PASSWORD: ${{ secrets.SAAS_DB_PASSWORD }}
|
||||
SAAS_DB_PROJECT_REF: ${{ secrets.SAAS_DB_PROJECT_REF }}
|
||||
SUPABASE_EDGE_FUNCTION_SECRET: ${{ secrets.SUPABASE_EDGE_FUNCTION_SECRET }}
|
||||
PAYG_METER_ENDPOINT: ${{ steps.config.outputs.meter_endpoint }}
|
||||
STIRLING_KEYGEN_ENABLED: ${{ secrets.KEYGEN_ACCOUNT_ID != '' && secrets.KEYGEN_API_TOKEN != '' && secrets.KEYGEN_POLICY_ID != '' }}
|
||||
KEYGEN_ACCOUNT_ID: ${{ secrets.KEYGEN_ACCOUNT_ID }}
|
||||
KEYGEN_API_TOKEN: ${{ secrets.KEYGEN_API_TOKEN }}
|
||||
KEYGEN_POLICY_ID: ${{ secrets.KEYGEN_POLICY_ID }}
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
VOYAGE_API_KEY: ${{ secrets.VOYAGE_API_KEY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
BASE_URL="http://${VPS_HOST}:${FRONTEND_PORT}"
|
||||
|
||||
yaml() {
|
||||
printf "'%s'" "$(printf '%s' "$1" | sed -e "s/'/''/g" -e 's/\$/$$/g')"
|
||||
}
|
||||
|
||||
ENGINE_SECRET="$(openssl rand -hex 32)"
|
||||
AI_BACKEND_VARS="
|
||||
SYSTEM_AIENGINE_ENABLED: \"true\"
|
||||
SYSTEM_AIENGINE_URL: \"http://saas-engine:5001\"
|
||||
APP_AI_SERVICEBASEURL: \"http://saas-engine:5001\"
|
||||
STIRLING_ENGINE_SHARED_SECRET: $(yaml "$ENGINE_SECRET")"
|
||||
AI_SERVICE="
|
||||
|
||||
saas-engine:
|
||||
container_name: stirling-saas-dev-engine
|
||||
image: ${IMAGE_BASE}:saas-engine-${IMAGE_TAG}
|
||||
environment:
|
||||
ANTHROPIC_API_KEY: $(yaml "$ANTHROPIC_API_KEY")
|
||||
VOYAGE_API_KEY: $(yaml "$VOYAGE_API_KEY")
|
||||
STIRLING_ENGINE_SHARED_SECRET: $(yaml "$ENGINE_SECRET")
|
||||
restart: on-failure:5"
|
||||
|
||||
cat > docker-compose.yml << EOF
|
||||
version: '3.3'
|
||||
services:
|
||||
saas-backend:
|
||||
container_name: stirling-saas-dev-backend
|
||||
image: ${IMAGE_BASE}:saas-backend-${IMAGE_TAG}
|
||||
ports:
|
||||
- "${BACKEND_PORT}:8080"
|
||||
volumes:
|
||||
- ${DEPLOY_DIR}/config:/configs:rw
|
||||
- ${DEPLOY_DIR}/logs:/logs:rw
|
||||
- ${DEPLOY_DIR}/storage:/storage:rw
|
||||
environment:
|
||||
SPRING_PROFILES_ACTIVE: "saas"
|
||||
DISABLE_ADDITIONAL_FEATURES: "false"
|
||||
SAAS_DB_URL: $(yaml "$SAAS_DB_URL")
|
||||
SAAS_DB_USERNAME: $(yaml "$SAAS_DB_USERNAME")
|
||||
SAAS_DB_PASSWORD: $(yaml "$SAAS_DB_PASSWORD")
|
||||
SAAS_DB_PROJECT_REF: $(yaml "$SAAS_DB_PROJECT_REF")
|
||||
SUPABASE_EDGE_FUNCTION_SECRET: $(yaml "$SUPABASE_EDGE_FUNCTION_SECRET")
|
||||
PAYG_METER_ENDPOINT: $(yaml "$PAYG_METER_ENDPOINT")
|
||||
STIRLING_KEYGEN_ENABLED: $(yaml "$STIRLING_KEYGEN_ENABLED")
|
||||
KEYGEN_ACCOUNT_ID: $(yaml "$KEYGEN_ACCOUNT_ID")
|
||||
KEYGEN_API_TOKEN: $(yaml "$KEYGEN_API_TOKEN")
|
||||
KEYGEN_POLICY_ID: $(yaml "$KEYGEN_POLICY_ID")
|
||||
SYSTEM_DEFAULTLOCALE: en-US
|
||||
SYSTEM_MAXFILESIZE: "100"
|
||||
METRICS_ENABLED: "true"
|
||||
SYSTEM_GOOGLEVISIBILITY: "false"
|
||||
SWAGGER_SERVER_URL: "${BASE_URL}"
|
||||
baseUrl: "${BASE_URL}"${AI_BACKEND_VARS}
|
||||
restart: on-failure:5
|
||||
|
||||
saas-frontend:
|
||||
container_name: stirling-saas-dev-frontend
|
||||
image: ${IMAGE_BASE}:saas-frontend-${IMAGE_TAG}
|
||||
ports:
|
||||
- "${FRONTEND_PORT}:80"
|
||||
environment:
|
||||
VITE_API_BASE_URL: "http://saas-backend:8080"
|
||||
depends_on:
|
||||
- saas-backend
|
||||
restart: on-failure:5${AI_SERVICE}
|
||||
EOF
|
||||
|
||||
SSH_OPTS=(-i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null)
|
||||
|
||||
scp "${SSH_OPTS[@]}" docker-compose.yml "${VPS_USERNAME}@${VPS_HOST}:/tmp/saas-dev-docker-compose.yml"
|
||||
|
||||
ssh "${SSH_OPTS[@]}" -T "${VPS_USERNAME}@${VPS_HOST}" << ENDSSH
|
||||
set -e
|
||||
mkdir -p ${DEPLOY_DIR}/{config,logs,storage}
|
||||
mv /tmp/saas-dev-docker-compose.yml ${DEPLOY_DIR}/docker-compose.yml
|
||||
chmod 600 ${DEPLOY_DIR}/docker-compose.yml
|
||||
cd ${DEPLOY_DIR}
|
||||
printf '%s' "${GHCR_TOKEN}" | docker login ghcr.io -u "${GHCR_USER}" --password-stdin
|
||||
docker-compose down --remove-orphans 2>/dev/null || true
|
||||
docker-compose pull
|
||||
docker-compose up -d
|
||||
docker logout ghcr.io >/dev/null 2>&1 || true
|
||||
docker image prune -af --filter "until=336h" --filter "label!=keep=true" || true
|
||||
ENDSSH
|
||||
|
||||
- name: Wait for the backend to answer
|
||||
env:
|
||||
VPS_HOST: ${{ secrets.NEW_VPS_HOST }}
|
||||
run: |
|
||||
URL="http://${VPS_HOST}:${BACKEND_PORT}/api/v1/info/status"
|
||||
for i in $(seq 1 60); do
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 "$URL" || true)
|
||||
if [ "$code" = "200" ]; then echo "Healthy after $((i * 10))s"; exit 0; fi
|
||||
sleep 10
|
||||
done
|
||||
echo "::error::SaaS dev backend did not become healthy within 10 minutes"
|
||||
exit 1
|
||||
|
||||
- name: Cleanup temporary files
|
||||
if: always()
|
||||
run: rm -f ../private.key docker-compose.yml
|
||||
continue-on-error: true
|
||||
@@ -113,6 +113,16 @@ class RequestUriUtilsTest {
|
||||
assertTrue(RequestUriUtils.isFrontendRoute("", "/split-pdf"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsFrontendRoute_editorRouteOwnedByFrontend() {
|
||||
// /editor (and its tool routes) is an SPA route: a direct-nav/refresh must
|
||||
// serve index.html, not the auth filter's 302-to-/login. Regression test for
|
||||
// the editor moving from / to /editor, whose refresh bounced processor users
|
||||
// to the processor because the redirect dropped the return path.
|
||||
assertTrue(RequestUriUtils.isFrontendRoute("", "/editor"));
|
||||
assertTrue(RequestUriUtils.isFrontendRoute("/app", "/app/editor"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsFrontendRoute_filesRouteOwnedByFrontend() {
|
||||
// /files and /files/<folder-uuid> are FileManagerView routes - they
|
||||
|
||||
+3
-3
@@ -357,12 +357,12 @@ public class SecurityConfiguration {
|
||||
req -> {
|
||||
String uri = req.getRequestURI();
|
||||
String contextPath = req.getContextPath();
|
||||
// Check if it's a public auth endpoint or static
|
||||
// resource
|
||||
return RequestUriUtils.isStaticResource(
|
||||
contextPath, uri)
|
||||
|| RequestUriUtils.isPublicAuthEndpoint(
|
||||
uri, contextPath);
|
||||
uri, contextPath)
|
||||
|| RequestUriUtils.isFrontendRoute(
|
||||
contextPath, uri);
|
||||
})
|
||||
.permitAll()
|
||||
.anyRequest()
|
||||
|
||||
@@ -4141,7 +4141,6 @@ mobileShort = "Mobile"
|
||||
mobileUpload = "Mobile Upload"
|
||||
mobileUploadNotAvailable = "Mobile upload not enabled"
|
||||
moreOptions = "More options"
|
||||
myFiles = "My Files"
|
||||
nextFile = "Next file"
|
||||
noFiles = "No files available"
|
||||
noFilesFound = "No files found matching your search"
|
||||
@@ -4242,9 +4241,9 @@ duplicateFailed = "Could not duplicate file"
|
||||
expand = "Expand sidebar"
|
||||
googleDrive = "Google Drive"
|
||||
googleDriveDisabled = "Google Drive is not configured"
|
||||
leaveMyFiles = "Leave My Files"
|
||||
leaveMyFiles = "Leave File library"
|
||||
library = "PDF Library"
|
||||
myFiles = "My Files"
|
||||
myFiles = "File library"
|
||||
noFiles = "No files yet"
|
||||
openFileManager = "Browse all files & folders"
|
||||
openFromComputer = "Open from computer"
|
||||
@@ -4287,7 +4286,7 @@ addToWorkspaceCount = "Add {{count}} to workspace"
|
||||
allFiles = "All files"
|
||||
back = "Back"
|
||||
backToFolder = "Back to {{folder}}"
|
||||
backToMyFiles = "Back to My Files"
|
||||
backToMyFiles = "Back to File library"
|
||||
breadcrumbs = "Folder path"
|
||||
bulkActions = "Actions"
|
||||
cancel = "Cancel"
|
||||
@@ -4339,7 +4338,6 @@ localFoldersUnavailable = "Folders are cloud-only - save a file to the cloud to
|
||||
moveSkippedRemote_one = "{{count}} file couldn't be moved on the server (no permission or already deleted)."
|
||||
moveSkippedRemote_other = "{{count}} files couldn't be moved on the server (no permission or already deleted)."
|
||||
moveTo = "Move to…"
|
||||
myFiles = "My Files"
|
||||
newFolder = "New folder"
|
||||
newFolderStorageDisabled = "Server folder storage isn't enabled. Ask your admin to turn it on."
|
||||
newFolderTabUnavailable = "Switch to All or Cloud to create folders."
|
||||
@@ -9173,7 +9171,6 @@ appEditor = "Editor"
|
||||
appProcessor = "Processor"
|
||||
linkAccount = "Link Stirling account"
|
||||
primaryNav = "Primary navigation"
|
||||
switchApp = "Switch app"
|
||||
|
||||
[portal.shell.topbar]
|
||||
closeNav = "Close navigation"
|
||||
@@ -9652,6 +9649,16 @@ automate = "Automate"
|
||||
config = "Config"
|
||||
files = "Files"
|
||||
|
||||
[quickNav]
|
||||
editor = "Editor"
|
||||
home = "Stirling"
|
||||
invite = "Invite"
|
||||
landmark = "Quick navigation"
|
||||
noProcessorAccess = "Ask an admin for processor access"
|
||||
notifications = "Notifications"
|
||||
processor = "Processor"
|
||||
reader = "Reader"
|
||||
|
||||
[read]
|
||||
tags = "view,open,display,read,viewer,PDF viewer,PDF reader,open PDF,view PDF,display PDF,preview,browse"
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Suspense, lazy } from "react";
|
||||
import { Routes, Route } from "react-router-dom";
|
||||
import { AppProviders } from "@app/components/AppProviders";
|
||||
import { AppFrame } from "@app/components/layout/AppFrame";
|
||||
import { AppLayout } from "@app/components/AppLayout";
|
||||
import { LoadingFallback } from "@app/components/shared/LoadingFallback";
|
||||
import { ThemeProvider } from "@app/components/shared/ThemeProvider";
|
||||
@@ -53,18 +54,21 @@ export default function App() {
|
||||
}
|
||||
/>
|
||||
|
||||
{/* All other routes need AppProviders for backend integration */}
|
||||
<Route
|
||||
path="*"
|
||||
element={
|
||||
<AppProviders>
|
||||
<AppLayout>
|
||||
<HomePage />
|
||||
<Onboarding />
|
||||
</AppLayout>
|
||||
</AppProviders>
|
||||
}
|
||||
/>
|
||||
{/* The app, under a shared frame so the rail renders once outside it. */}
|
||||
<Route element={<AppFrame />}>
|
||||
{/* All other routes need AppProviders for backend integration */}
|
||||
<Route
|
||||
path="*"
|
||||
element={
|
||||
<AppProviders>
|
||||
<AppLayout>
|
||||
<HomePage />
|
||||
<Onboarding />
|
||||
</AppLayout>
|
||||
</AppProviders>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
</Routes>
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
@@ -173,7 +173,7 @@ const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
|
||||
mb="xs"
|
||||
style={{ paddingLeft: "1rem" }}
|
||||
>
|
||||
{t("fileManager.myFiles", "My Files")}
|
||||
{t("fileSidebar.myFiles", "File library")}
|
||||
</Text>
|
||||
{buttons}
|
||||
</Stack>
|
||||
|
||||
@@ -121,7 +121,7 @@ export function FolderTreePanel({ active }: FolderTreePanelProps) {
|
||||
<div className="folder-tree-panel-inner">
|
||||
<div className="folder-tree-panel-header">
|
||||
<span className="folder-tree-panel-title">
|
||||
{t("filesPage.myFiles", "My Files")}
|
||||
{t("fileSidebar.myFiles", "File library")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Stores the route the user came from when they open files into the
|
||||
* workbench from My Files. Lets the workbench show a "Back to My Files"
|
||||
* workbench from the file library. Lets the workbench show a "Back to File library"
|
||||
* affordance and return to the exact folder they were browsing.
|
||||
*
|
||||
* Persisted in sessionStorage so a hard reload keeps the return path
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
/* ========== APP FRAME ========== */
|
||||
/* The rail's column, then whichever app is mounted, so a switch changes only the app. */
|
||||
.app-frame {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
height: 100dvh; /* track mobile browser chrome */
|
||||
overflow: hidden;
|
||||
background-color: var(--c-bg);
|
||||
}
|
||||
|
||||
/* min-width: 0 so the app shrinks instead of forcing the frame past the window. */
|
||||
.app-frame__content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* The rail hides itself below the mobile breakpoint - see QuickNavRailContainer.css. */
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Suspense } from "react";
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { LoadingFallback } from "@app/components/shared/LoadingFallback";
|
||||
import { QuickNavHostProvider } from "@app/contexts/QuickNavHostContext";
|
||||
import { QuickNavRailHost } from "@app/components/shared/quickNav/QuickNavRailHost";
|
||||
import "@app/components/layout/AppFrame.css";
|
||||
|
||||
/** The rail renders once outside both apps; Suspense sits inside it, not above. */
|
||||
export function AppFrame() {
|
||||
return (
|
||||
<QuickNavHostProvider>
|
||||
<div className="app-frame">
|
||||
<QuickNavRailHost />
|
||||
<div className="app-frame__content">
|
||||
<Suspense fallback={<LoadingFallback />}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</QuickNavHostProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { useSuppressQuickNavRail } from "@app/contexts/QuickNavHostContext";
|
||||
|
||||
/** Pages that aren't the app: inside the frame for its providers, but with no rail. */
|
||||
export function NoAppChrome() {
|
||||
useSuppressQuickNavRail();
|
||||
return <Outlet />;
|
||||
}
|
||||
@@ -12,10 +12,8 @@
|
||||
.workbenchBarReopenTab {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
/* Right-align with the retract handle inside the bar: the bar's right
|
||||
margin (--nav-gutter) + 1px border + 8px bar padding + the handle's own
|
||||
6px inset. */
|
||||
right: calc(var(--nav-gutter) + 15px);
|
||||
/* Aligns with the retract handle: 8px bar padding plus its own 6px inset. */
|
||||
right: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, Suspense, lazy } from "react";
|
||||
import { useState, useEffect, useRef, Suspense, lazy } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
|
||||
import { Box, Loader, Center, Stack, Text } from "@mantine/core";
|
||||
@@ -15,6 +15,7 @@ import { VIEWER_SUPPORTED_EXTENSIONS } from "@app/utils/fileUtils";
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
import { useSigningOverlay } from "@app/contexts/SigningOverlayContext";
|
||||
import { useCookieConsent } from "@app/hooks/useCookieConsent";
|
||||
import { useIsPhone } from "@app/hooks/useIsMobile";
|
||||
import styles from "@app/components/layout/Workbench.module.css";
|
||||
|
||||
import WorkbenchBar from "@app/components/shared/WorkbenchBar";
|
||||
@@ -58,10 +59,13 @@ export default function Workbench() {
|
||||
setPageEditorFunctions,
|
||||
setSidebarsVisible,
|
||||
customWorkbenchViews,
|
||||
readerMode,
|
||||
} = useToolWorkflow();
|
||||
|
||||
const { handleToolSelect } = useToolWorkflow();
|
||||
const { overlay: signingOverlay } = useSigningOverlay();
|
||||
// Below this width the rail, and the bell it carries, is gone.
|
||||
const isPhone = useIsPhone();
|
||||
|
||||
// Get navigation state - this is the source of truth
|
||||
const { selectedTool: selectedToolId } = useNavigationState();
|
||||
@@ -92,8 +96,20 @@ export default function Workbench() {
|
||||
!isBaseWorkbench(currentView) ||
|
||||
// Shared signing drives the viewer from the sidebar with no file in context.
|
||||
(currentView === "viewer" && !!signingOverlay?.file);
|
||||
const showWorkbenchBar = topControlsAvailable && hasWorkbenchContent;
|
||||
const showFloatingSearch = topControlsAvailable && !hasWorkbenchContent;
|
||||
// Reading hides the bar; the rail's Reader entry is the way back.
|
||||
const showWorkbenchBar =
|
||||
topControlsAvailable && hasWorkbenchContent && !readerMode;
|
||||
const showFloatingSearch =
|
||||
topControlsAvailable && !hasWorkbenchContent && !readerMode;
|
||||
|
||||
// On the transition, so reading sets the toolbar's start state without locking it.
|
||||
const prevReaderModeRef = useRef(readerMode);
|
||||
useEffect(() => {
|
||||
if (readerMode !== prevReaderModeRef.current) {
|
||||
setViewerToolbarCollapsed(readerMode);
|
||||
prevReaderModeRef.current = readerMode;
|
||||
}
|
||||
}, [readerMode]);
|
||||
|
||||
const handlePreviewClose = () => {
|
||||
setPreviewFile(null);
|
||||
@@ -126,7 +142,7 @@ export default function Workbench() {
|
||||
}
|
||||
}
|
||||
|
||||
// The "My Files" workbench is available regardless of whether files are
|
||||
// The file-library workbench is available regardless of whether files are
|
||||
// currently loaded into the workbench - it lives on top of the IDB store.
|
||||
if (currentView === "myFiles") {
|
||||
return <FileManagerView />;
|
||||
@@ -249,10 +265,8 @@ export default function Workbench() {
|
||||
data-tour="workbench"
|
||||
style={{ backgroundColor: "var(--c-bg)", minWidth: 0 }}
|
||||
>
|
||||
{/* The bell normally rides in the workbench bar. Wherever that bar is not shown - My Files,
|
||||
an empty workbench, a custom view without top controls - it gets its own corner, rather
|
||||
than those being the places a user cannot see that something of theirs failed. */}
|
||||
{!showWorkbenchBar && (
|
||||
{/* Phone only: above that the rail carries the bell, and here no bar does. */}
|
||||
{isPhone && !showWorkbenchBar && (
|
||||
<div style={{ position: "absolute", top: 12, right: 12, zIndex: 20 }}>
|
||||
<NotificationBell />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
/* ========== WORKSPACE FRAME ========== */
|
||||
/* Rail and sidebar side by side, full height. Shared by both apps. */
|
||||
.workspace-frame {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
flex-shrink: 0;
|
||||
background-color: var(--c-bg);
|
||||
}
|
||||
|
||||
/* On mobile the sidebar is a fixed drawer, so the frame stops laying out. */
|
||||
@media (max-width: 48rem) {
|
||||
.workspace-frame {
|
||||
display: block;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@
|
||||
position: relative;
|
||||
padding: var(--sp-2, 0.5rem);
|
||||
border: none;
|
||||
border-radius: var(--radius-md, 0.375rem);
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: var(--c-text-muted);
|
||||
cursor: pointer;
|
||||
@@ -48,6 +48,12 @@
|
||||
box-shadow: 0 10px 30px rgb(0 0 0 / 25%);
|
||||
}
|
||||
|
||||
/* The rail's bell is at the foot of a full-height column, so its panel rises beside it. */
|
||||
.notification-bell__panel--rail {
|
||||
inset-inline-start: calc(var(--nav-rail-w) + var(--nav-gutter));
|
||||
inset-block-end: var(--nav-gutter);
|
||||
}
|
||||
|
||||
.notification-bell__heading {
|
||||
margin: 0 0 var(--sp-2, 0.5rem);
|
||||
font-size: 0.875rem;
|
||||
|
||||
@@ -1,27 +1,15 @@
|
||||
import {
|
||||
Fragment,
|
||||
useEffect,
|
||||
useId,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useLayoutEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { BellIcon, Button } from "@app/ui";
|
||||
import DividerWithText from "@app/components/shared/DividerWithText";
|
||||
import { useNotifications } from "@app/hooks/useNotifications";
|
||||
import { useNotificationActions } from "@app/components/notifications/notificationActions";
|
||||
import { NotificationItem } from "@app/components/notifications/NotificationItem";
|
||||
import { NotificationPanel } from "@app/components/notifications/NotificationPanel";
|
||||
import { useNotificationsAvailable } from "@app/components/notifications/useNotificationsAvailable";
|
||||
import "@app/components/notifications/NotificationBell.css";
|
||||
|
||||
/**
|
||||
* Renders whatever the server sends without knowing which subsystem produced it or what its actions
|
||||
* mean, so a new source or failure kind needs no change here. In core because both shells mount it.
|
||||
*/
|
||||
/** For the narrow layouts where the rail, which carries the bell, is off screen. */
|
||||
export function NotificationBell() {
|
||||
// A build with no notifications API gets no bell at all, rather than one that polls a
|
||||
// nonexistent endpoint forever to show nothing.
|
||||
// No API means no bell at all, rather than one polling an endpoint that isn't there.
|
||||
const available = useNotificationsAvailable();
|
||||
if (!available) return null;
|
||||
return <MountedNotificationBell />;
|
||||
@@ -29,14 +17,10 @@ export function NotificationBell() {
|
||||
|
||||
function MountedNotificationBell() {
|
||||
const { t } = useTranslation();
|
||||
const { notifications, unreadCount, documentStateFor, markAllSeen } =
|
||||
useNotifications();
|
||||
const { unreadCount } = useNotifications();
|
||||
const registry = useNotificationActions();
|
||||
const [open, setOpen] = useState(false);
|
||||
const container = useRef<HTMLDivElement>(null);
|
||||
const headingId = useId();
|
||||
// Where the new ones stop, frozen when the panel opens (opening marks everything read).
|
||||
const [firstSeenId, setFirstSeenId] = useState<string | null>(null);
|
||||
// Viewport-fixed, because the workbench bar clips its own overflow.
|
||||
const [anchor, setAnchor] = useState<{ top: number; right: number } | null>(
|
||||
null,
|
||||
@@ -61,54 +45,17 @@ function MountedNotificationBell() {
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
// Opening marks them read, not closing: waiting would leave the badge lit while they read.
|
||||
const toggle = () => {
|
||||
setOpen((wasOpen) => {
|
||||
if (!wasOpen) {
|
||||
// Before marking, or there is nothing left to read.
|
||||
setFirstSeenId(notifications[unreadCount]?.id ?? null);
|
||||
markAllSeen();
|
||||
}
|
||||
return !wasOpen;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* How many count as new. No boundary id means all of them were; one that has since left the list
|
||||
* leaves nothing to divide on, so it reads as none rather than guessing at a row.
|
||||
*/
|
||||
const boundaryIndex = firstSeenId
|
||||
? notifications.findIndex((notification) => notification.id === firstSeenId)
|
||||
: notifications.length;
|
||||
const dividedAt = Math.max(0, boundaryIndex);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const closeOnOutside = (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement;
|
||||
if (!container.current?.contains(target)) setOpen(false);
|
||||
};
|
||||
const closeOnEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", closeOnOutside);
|
||||
document.addEventListener("keydown", closeOnEscape);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", closeOnOutside);
|
||||
document.removeEventListener("keydown", closeOnEscape);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div className="notification-bell" ref={container}>
|
||||
<Button
|
||||
variant="quiet"
|
||||
size="md"
|
||||
shape="circle"
|
||||
className="notification-bell__trigger"
|
||||
// Read by the panel's outside-click handler.
|
||||
data-notifications-trigger
|
||||
aria-label={t("notifications.open", "Notifications")}
|
||||
aria-expanded={open}
|
||||
onClick={toggle}
|
||||
onClick={() => setOpen((wasOpen) => !wasOpen)}
|
||||
>
|
||||
<BellIcon />
|
||||
{unreadCount > 0 && (
|
||||
@@ -119,53 +66,11 @@ function MountedNotificationBell() {
|
||||
</Button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
className="notification-bell__panel"
|
||||
role="dialog"
|
||||
// Named by its own heading: a dialog with no accessible name is announced as just "dialog".
|
||||
aria-labelledby={headingId}
|
||||
<NotificationPanel
|
||||
onClose={() => setOpen(false)}
|
||||
registry={registry}
|
||||
style={anchor ? { top: anchor.top, right: anchor.right } : undefined}
|
||||
>
|
||||
<h2 className="notification-bell__heading" id={headingId}>
|
||||
{t("notifications.title", "Notifications")}
|
||||
</h2>
|
||||
|
||||
{notifications.length === 0 ? (
|
||||
<p className="notification-bell__empty">
|
||||
{t("notifications.empty", "Nothing to report.")}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="notification-bell__list">
|
||||
{notifications.map((notification, index) => (
|
||||
<Fragment key={notification.id}>
|
||||
{index === 0 && dividedAt > 0 && (
|
||||
<li aria-hidden>
|
||||
<DividerWithText
|
||||
text={t("notifications.section.new", "New")}
|
||||
/>
|
||||
</li>
|
||||
)}
|
||||
{/* Only with something on both sides: a lone "Earlier" over everything says
|
||||
nothing the empty badge has not. */}
|
||||
{index === dividedAt && dividedAt > 0 && (
|
||||
<li aria-hidden>
|
||||
<DividerWithText
|
||||
text={t("notifications.section.earlier", "Earlier")}
|
||||
/>
|
||||
</li>
|
||||
)}
|
||||
<NotificationItem
|
||||
notification={notification}
|
||||
unread={index < dividedAt}
|
||||
documentState={documentStateFor(notification)}
|
||||
registry={registry}
|
||||
onDismissPanel={() => setOpen(false)}
|
||||
/>
|
||||
</Fragment>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { Fragment, useEffect, useId, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import DividerWithText from "@app/components/shared/DividerWithText";
|
||||
import { useNotifications } from "@app/hooks/useNotifications";
|
||||
import type { ClientActionRegistry } from "@app/components/notifications/notificationActions";
|
||||
import { NotificationItem } from "@app/components/notifications/NotificationItem";
|
||||
import "@app/components/notifications/NotificationBell.css";
|
||||
|
||||
/** Named so a trigger in another tree can point at it with aria-controls. */
|
||||
export const NOTIFICATIONS_PANEL_ID = "quick-nav-notifications-panel";
|
||||
|
||||
export interface NotificationPanelProps {
|
||||
onClose: () => void;
|
||||
id?: string;
|
||||
/** Passed in: its document handover has to run whether the panel is open or not. */
|
||||
registry: ClientActionRegistry;
|
||||
style?: React.CSSProperties;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** Mounted only while open, since mounting is what marks everything read. */
|
||||
export function NotificationPanel({
|
||||
onClose,
|
||||
registry,
|
||||
id,
|
||||
style,
|
||||
className,
|
||||
}: NotificationPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const { notifications, unreadCount, documentStateFor, markAllSeen } =
|
||||
useNotifications();
|
||||
const panel = useRef<HTMLDivElement>(null);
|
||||
const headingId = useId();
|
||||
// Frozen on open, since opening marks them all read.
|
||||
const [firstSeenId, setFirstSeenId] = useState<string | null>(null);
|
||||
|
||||
// On mount, not on close: waiting leaves the badge lit while they read.
|
||||
const marked = useRef(false);
|
||||
useEffect(() => {
|
||||
if (marked.current) return;
|
||||
marked.current = true;
|
||||
// Before marking, or there is nothing left to divide on.
|
||||
setFirstSeenId(notifications[unreadCount]?.id ?? null);
|
||||
markAllSeen();
|
||||
}, [notifications, unreadCount, markAllSeen]);
|
||||
|
||||
// No boundary means all were new; one that has left the list means none.
|
||||
const boundaryIndex = firstSeenId
|
||||
? notifications.findIndex((notification) => notification.id === firstSeenId)
|
||||
: notifications.length;
|
||||
const dividedAt = Math.max(0, boundaryIndex);
|
||||
|
||||
// Focus goes back to the opener only if it is still inside the panel on close.
|
||||
useEffect(() => {
|
||||
const opener = document.activeElement as HTMLElement | null;
|
||||
panel.current?.focus();
|
||||
return () => {
|
||||
if (panel.current?.contains(document.activeElement)) opener?.focus();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const closeOnOutside = (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement;
|
||||
if (panel.current?.contains(target)) return;
|
||||
// A trigger closes this itself; counting it as outside would reopen it.
|
||||
if (target.closest?.("[data-notifications-trigger]")) return;
|
||||
onClose();
|
||||
};
|
||||
const closeOnEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("mousedown", closeOnOutside);
|
||||
document.addEventListener("keydown", closeOnEscape);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", closeOnOutside);
|
||||
document.removeEventListener("keydown", closeOnEscape);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={panel}
|
||||
className={
|
||||
className
|
||||
? `notification-bell__panel ${className}`
|
||||
: "notification-bell__panel"
|
||||
}
|
||||
id={id}
|
||||
role="dialog"
|
||||
tabIndex={-1}
|
||||
aria-labelledby={headingId}
|
||||
style={style}
|
||||
>
|
||||
<h2 className="notification-bell__heading" id={headingId}>
|
||||
{t("notifications.title", "Notifications")}
|
||||
</h2>
|
||||
|
||||
{notifications.length === 0 ? (
|
||||
<p className="notification-bell__empty">
|
||||
{t("notifications.empty", "Nothing to report.")}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="notification-bell__list">
|
||||
{notifications.map((notification, index) => (
|
||||
<Fragment key={notification.id}>
|
||||
{index === 0 && dividedAt > 0 && (
|
||||
<li aria-hidden>
|
||||
<DividerWithText
|
||||
text={t("notifications.section.new", "New")}
|
||||
/>
|
||||
</li>
|
||||
)}
|
||||
{/* Only with unread rows above it. */}
|
||||
{index === dividedAt && dividedAt > 0 && (
|
||||
<li aria-hidden>
|
||||
<DividerWithText
|
||||
text={t("notifications.section.earlier", "Earlier")}
|
||||
/>
|
||||
</li>
|
||||
)}
|
||||
<NotificationItem
|
||||
notification={notification}
|
||||
unread={index < dividedAt}
|
||||
documentState={documentStateFor(notification)}
|
||||
registry={registry}
|
||||
onDismissPanel={onClose}
|
||||
/>
|
||||
</Fragment>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,12 +11,7 @@ interface AppSwitchMenuItemsProps {
|
||||
onSwitch: (app: AppSwitchTarget) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The editor / processor items for the app-switch menu. Rendered inside the
|
||||
* BrandSwitcher's logo dropdown, which both apps use as their switcher. The
|
||||
* mark is the shared <BrandMark>, which recolours itself from the theme
|
||||
* tokens, so no colour-scheme prop needs threading down here.
|
||||
*/
|
||||
/** The editor / processor items for an app-switch menu. */
|
||||
export function AppSwitchMenuItems({
|
||||
current,
|
||||
onSwitch,
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import { Logo } from "@app/ui/Logo";
|
||||
|
||||
export interface AppSwitcherProps {
|
||||
/** Icon-only brand mark for the collapsed rail. */
|
||||
collapsed?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sidebar brand header. Core has no admin portal to switch to, so it just
|
||||
* shows the Stirling logo. Builds that bundle the portal (proprietary/saas)
|
||||
* shadow this with a version whose logo doubles as the editor⇄processor
|
||||
* switcher.
|
||||
*/
|
||||
export function AppSwitcher({ collapsed }: AppSwitcherProps) {
|
||||
return (
|
||||
<Logo
|
||||
variant={collapsed ? "iconOnly" : "iconAndText"}
|
||||
iconHeight="1.6rem"
|
||||
textHeight="1.3rem"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
/* Logo + app-switch dropdown, shared between the editor and the processor.
|
||||
The logo itself is the trigger (its mark morphs into a chevron on hover). */
|
||||
.sui-brand-switcher {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Tighten the ghost-button padding so the lockup sits flush like a plain logo,
|
||||
and negative-margin it back so the hover surface still extends past the text. */
|
||||
.sui-brand-switcher__trigger.sui-btn {
|
||||
--button-padding-x: 0.375rem;
|
||||
margin-inline: -0.375rem;
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { BrandSwitcher } from "@app/components/shared/BrandSwitcher";
|
||||
|
||||
const meta: Meta<typeof BrandSwitcher> = {
|
||||
title: "Brand/BrandSwitcher",
|
||||
component: BrandSwitcher,
|
||||
parameters: { layout: "centered" },
|
||||
args: { current: "processor", onSwitch: () => {} },
|
||||
argTypes: {
|
||||
current: { control: "inline-radio", options: ["editor", "processor"] },
|
||||
},
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof BrandSwitcher>;
|
||||
|
||||
export const Playground: Story = {};
|
||||
@@ -1,57 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button, Dropdown } from "@app/ui";
|
||||
import { Logo } from "@app/ui/Logo";
|
||||
import { BrandMark } from "@app/components/shared/BrandMark";
|
||||
import {
|
||||
AppSwitchMenuItems,
|
||||
type AppSwitchTarget,
|
||||
} from "@app/components/shared/AppSwitch";
|
||||
import "@app/components/shared/BrandSwitcher.css";
|
||||
|
||||
interface BrandSwitcherProps {
|
||||
/** The app this is rendered in (shown active in the menu). */
|
||||
current: AppSwitchTarget;
|
||||
/** Called with the selected app (only for the non-current one). */
|
||||
onSwitch: (app: AppSwitchTarget) => void;
|
||||
/** Icon-only: drop the wordmark, keep the morphing mark as the trigger. */
|
||||
collapsed?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Brand lockup that doubles as the editor⇄processor switcher. The whole logo
|
||||
* is the dropdown trigger: on hover / focus / open the mark morphs into a
|
||||
* downward chevron (see BrandMark), so no separate chevron button is needed.
|
||||
* Shared so the editor and the processor present one identical header.
|
||||
*/
|
||||
export function BrandSwitcher({
|
||||
current,
|
||||
onSwitch,
|
||||
collapsed = false,
|
||||
className,
|
||||
}: BrandSwitcherProps) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className={`sui-brand-switcher${className ? ` ${className}` : ""}`}>
|
||||
<Dropdown.Root align="start" open={open} onOpenChange={setOpen}>
|
||||
<Dropdown.Trigger>
|
||||
<Button
|
||||
variant="quiet"
|
||||
data-brandmark-morph
|
||||
className={`sui-brand-switcher__trigger${open ? " is-open" : ""}`}
|
||||
aria-label={t("portal.shell.sidebar.switchApp", "Switch app")}
|
||||
leftSection={<BrandMark height="1.6rem" />}
|
||||
>
|
||||
{!collapsed && <Logo variant="textOnly" textHeight="1.3rem" />}
|
||||
</Button>
|
||||
</Dropdown.Trigger>
|
||||
<Dropdown.Menu width="11rem">
|
||||
<AppSwitchMenuItems current={current} onSwitch={onSwitch} />
|
||||
</Dropdown.Menu>
|
||||
</Dropdown.Root>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
interface BrandTileProps {
|
||||
/** CSS length. Omit to let the caller's CSS size it. */
|
||||
size?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** The mark in a rounded square. Decorative: call sites carry the accessible name. */
|
||||
export function BrandTile({ size, className }: BrandTileProps) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 256 256"
|
||||
fill="none"
|
||||
style={size ? { width: size, height: size } : undefined}
|
||||
aria-hidden
|
||||
>
|
||||
<rect width="256" height="256" rx="58" fill="var(--c-brand-mark)" />
|
||||
<path
|
||||
d="M39.2638 127.834L155.374 32L155.375 121.499L39.2638 217.333L39.2638 127.834Z"
|
||||
fill="white"
|
||||
/>
|
||||
<path
|
||||
d="M159 124.5L159 88.5L216.728 38.4472L216.728 128.052L100.479 224L100.479 172L159 124.5Z"
|
||||
fill="white"
|
||||
fillOpacity="0.6"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
/* ========== FILE SIDEBAR ========== */
|
||||
|
||||
.file-sidebar {
|
||||
background-color: var(--c-bg);
|
||||
/* One solid panel, with a rule only on the workbench side, so it frames the document. */
|
||||
background-color: var(--c-surface);
|
||||
border-inline-end: 1px solid var(--c-border-subtle);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
@@ -37,12 +39,19 @@
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* ---- Brand header (logo / editor⇄processor switcher) ---- */
|
||||
.file-sidebar-brand {
|
||||
/* Flattened here; two classes deep to beat .sui-nav-surface without relying on order. */
|
||||
.file-sidebar .sui-nav-surface {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
/* ---- Header row (wordmark + collapse toggle) ---- */
|
||||
.file-sidebar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 40px;
|
||||
padding: 0 0.375rem;
|
||||
min-height: var(--nav-header-h);
|
||||
padding: 0 var(--nav-gutter);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -51,9 +60,9 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.file-sidebar[data-collapsed="true"] .file-sidebar-brand {
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
/* Collapsed the row holds only the toggle, so centre it. */
|
||||
.file-sidebar[data-collapsed="true"] .file-sidebar-header {
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
}
|
||||
.file-sidebar[data-collapsed="true"] .file-sidebar-collapse-toggle {
|
||||
|
||||
@@ -24,7 +24,6 @@ import { useViewer } from "@app/contexts/ViewerContext";
|
||||
import { useFileHandler } from "@app/hooks/useFileHandler";
|
||||
import { useAccountIdentity } from "@app/hooks/useAccountIdentity";
|
||||
import { useFreeCreditsSummary } from "@app/hooks/useFreeCreditsSummary";
|
||||
import { useOtherAppSwitch } from "@app/hooks/useOtherAppSwitch";
|
||||
import { useOpenPlan } from "@app/hooks/useOpenPlan";
|
||||
import { NavFooter } from "@app/components/shared/navFooter/NavFooter";
|
||||
import {
|
||||
@@ -32,8 +31,7 @@ import {
|
||||
useIndexedDBRevision,
|
||||
} from "@app/contexts/IndexedDBContext";
|
||||
import { GoogleDriveIcon } from "@app/components/shared/CloudStorageIcons";
|
||||
import { AppSwitcher } from "@app/components/shared/AppSwitcher";
|
||||
import { SidebarToggleIcon } from "@app/components/shared/SidebarToggleIcon";
|
||||
import { SidebarHeader } from "@app/components/shared/SidebarHeader";
|
||||
import type { StirlingFileStub } from "@app/types/fileContext";
|
||||
import FolderOpenIcon from "@mui/icons-material/FolderOpen";
|
||||
import FolderSpecialIcon from "@mui/icons-material/FolderSpecial";
|
||||
@@ -78,8 +76,9 @@ import { WATCHED_FOLDERS_ENABLED } from "@app/constants/featureFlags";
|
||||
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||
import "@app/components/shared/FileSidebar.css";
|
||||
|
||||
const COLLAPSED_WIDTH = "3.5rem";
|
||||
const EXPANDED_WIDTH = "16.25rem"; // ~260px
|
||||
// Shared with the processor sidebar via tokens, so the two cannot drift.
|
||||
const COLLAPSED_WIDTH = "var(--sidebar-collapsed-w)";
|
||||
const EXPANDED_WIDTH = "var(--sidebar-w)";
|
||||
|
||||
// Inlined to avoid a circular import with WatchedFoldersRegistration.
|
||||
const WATCHED_FOLDER_VIEW_ID = "watchedFolder";
|
||||
@@ -98,9 +97,11 @@ export interface FileSidebarProps {
|
||||
collapsed?: boolean;
|
||||
onToggleCollapse?: () => void;
|
||||
onOpenSettings?: () => void;
|
||||
/** Accessible name override for the toggle button. */
|
||||
/** The quick nav rail owns the account control, so the footer drops its own row. */
|
||||
accountHoisted?: boolean;
|
||||
/** Accessible name override for the collapse toggle. */
|
||||
toggleAriaLabel?: string;
|
||||
/** Icon override for the toggle button (e.g. back-arrow on /files). */
|
||||
/** Icon override for the collapse toggle (e.g. back-arrow on /files). */
|
||||
toggleIcon?: React.ReactNode;
|
||||
/** Override the Open-from-computer handler (e.g. upload to /files folder). */
|
||||
onUploadFiles?: (files: File[]) => void | Promise<void>;
|
||||
@@ -155,11 +156,12 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
collapsed = false,
|
||||
onToggleCollapse,
|
||||
onOpenSettings,
|
||||
accountHoisted = false,
|
||||
toggleAriaLabel,
|
||||
toggleIcon,
|
||||
onUploadFiles,
|
||||
onPickGoogleDriveFiles,
|
||||
extraAction,
|
||||
toggleAriaLabel,
|
||||
toggleIcon,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
@@ -249,7 +251,6 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
const { displayName, profilePictureUrl, isAnonymous } =
|
||||
useAccountIdentity();
|
||||
const credits = useFreeCreditsSummary();
|
||||
const otherApp = useOtherAppSwitch();
|
||||
const openPlan = useOpenPlan();
|
||||
|
||||
// Leaf files = user-visible files (excludes intermediate tool outputs)
|
||||
@@ -943,25 +944,12 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
</div>
|
||||
)}
|
||||
<div className="file-sidebar-inner">
|
||||
<div className="file-sidebar-brand">
|
||||
<AppSwitcher collapsed={collapsed} />
|
||||
{onToggleCollapse && (
|
||||
<ActionIcon
|
||||
variant="tertiary"
|
||||
size="md"
|
||||
className="file-sidebar-collapse-toggle"
|
||||
onClick={() => onToggleCollapse()}
|
||||
aria-label={
|
||||
toggleAriaLabel ??
|
||||
(collapsed
|
||||
? t("fileSidebar.expand", "Expand sidebar")
|
||||
: t("fileSidebar.collapse", "Collapse sidebar"))
|
||||
}
|
||||
>
|
||||
{toggleIcon ?? <SidebarToggleIcon size={18} />}
|
||||
</ActionIcon>
|
||||
)}
|
||||
</div>
|
||||
<SidebarHeader
|
||||
collapsed={collapsed}
|
||||
onToggleCollapse={onToggleCollapse}
|
||||
toggleAriaLabel={toggleAriaLabel}
|
||||
toggleIcon={toggleIcon}
|
||||
/>
|
||||
|
||||
{/* Box 1 — top controls (open / my files / cloud). No title. File
|
||||
search lives in the global super search (top bar), not here. */}
|
||||
@@ -984,7 +972,7 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
{/* Tooltips only fire when collapsed - when expanded the visible
|
||||
text label below already identifies each row, so a tooltip
|
||||
would just flash a duplicate. Distinct icons (UploadFile for
|
||||
"Open from computer" vs FolderOpen for "My Files") so the
|
||||
"Open from computer" vs FolderOpen for "File library") so the
|
||||
collapsed rail isn't two identical folder icons either. */}
|
||||
<Tooltip
|
||||
label={t("fileSidebar.openFromComputer", "Open from computer")}
|
||||
@@ -1003,7 +991,7 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
onClick={() => {
|
||||
// "Open from computer" goes straight to the native OS file
|
||||
// picker. The full file manager (recent + drives + folders)
|
||||
// is reachable via "My Files" below.
|
||||
// is reachable via "File library" below.
|
||||
nativeFileInputRef.current?.click();
|
||||
}}
|
||||
role="button"
|
||||
@@ -1080,7 +1068,7 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
)}
|
||||
|
||||
<Tooltip
|
||||
label={t("fileSidebar.myFiles", "My Files")}
|
||||
label={t("fileSidebar.myFiles", "File library")}
|
||||
position="right"
|
||||
withinPortal
|
||||
disabled={!collapsed}
|
||||
@@ -1094,7 +1082,7 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={t("fileSidebar.myFiles", "My Files")}
|
||||
aria-label={t("fileSidebar.myFiles", "File library")}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
@@ -1105,7 +1093,7 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
<FolderOpenIcon className="file-sidebar-action-icon" />
|
||||
{!collapsed && (
|
||||
<span className="file-sidebar-action-label sidebar-content-fade">
|
||||
{t("fileSidebar.myFiles", "My Files")}
|
||||
{t("fileSidebar.myFiles", "File library")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -1370,15 +1358,15 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
{/* Getting-started checklist, floating above the footer (SaaS only). */}
|
||||
<SidebarChecklistSlot collapsed={collapsed} />
|
||||
|
||||
{/* Box 3 — the shared footer: credits, app switch, account row. */}
|
||||
{/* Box 3 — the shared footer: credits, plan, and the account row unless hoisted. */}
|
||||
<NavFooter
|
||||
className="file-sidebar-footer-box"
|
||||
displayName={displayName}
|
||||
profilePictureUrl={profilePictureUrl}
|
||||
onOpenSettings={onOpenSettings}
|
||||
showAccount={!accountHoisted}
|
||||
credits={credits}
|
||||
onOpenPlan={openPlan ?? undefined}
|
||||
otherApp={otherApp}
|
||||
collapsed={collapsed}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Logo } from "@app/ui/Logo";
|
||||
import { SidebarToggleButton } from "@app/components/shared/SidebarToggleButton";
|
||||
|
||||
export interface SidebarHeaderProps {
|
||||
collapsed?: boolean;
|
||||
onToggleCollapse?: () => void;
|
||||
toggleAriaLabel?: string;
|
||||
toggleIcon?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** The wordmark and the collapse toggle; the brand mark sits in the rail beside it. */
|
||||
export function SidebarHeader({
|
||||
collapsed,
|
||||
onToggleCollapse,
|
||||
toggleAriaLabel,
|
||||
toggleIcon,
|
||||
className,
|
||||
}: SidebarHeaderProps) {
|
||||
return (
|
||||
<div className={`file-sidebar-header${className ? ` ${className}` : ""}`}>
|
||||
{!collapsed && <Logo variant="textOnly" textHeight="1.3rem" />}
|
||||
{onToggleCollapse && (
|
||||
<SidebarToggleButton
|
||||
collapsed={collapsed}
|
||||
onToggle={onToggleCollapse}
|
||||
ariaLabel={toggleAriaLabel}
|
||||
icon={toggleIcon}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ActionIcon } from "@app/ui/ActionIcon";
|
||||
import { SidebarToggleIcon } from "@app/components/shared/SidebarToggleIcon";
|
||||
|
||||
export interface SidebarToggleButtonProps {
|
||||
collapsed?: boolean;
|
||||
onToggle: () => void;
|
||||
ariaLabel?: string;
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
/** Opens and closes the sidebar; on /files the caller swaps in a back arrow. */
|
||||
export function SidebarToggleButton({
|
||||
collapsed,
|
||||
onToggle,
|
||||
ariaLabel,
|
||||
icon,
|
||||
}: SidebarToggleButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<ActionIcon
|
||||
variant="tertiary"
|
||||
size="md"
|
||||
className="file-sidebar-collapse-toggle"
|
||||
onClick={() => onToggle()}
|
||||
aria-label={
|
||||
ariaLabel ??
|
||||
(collapsed
|
||||
? t("fileSidebar.expand", "Expand sidebar")
|
||||
: t("fileSidebar.collapse", "Collapse sidebar"))
|
||||
}
|
||||
>
|
||||
{icon ?? <SidebarToggleIcon size={18} />}
|
||||
</ActionIcon>
|
||||
);
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import { addEventListenerWithCleanup } from "@app/utils/genericUtils";
|
||||
import { useTooltipPosition } from "@app/hooks/useTooltipPosition";
|
||||
import { TooltipTip } from "@app/types/tips";
|
||||
import { TooltipContent } from "@app/components/shared/tooltip/TooltipContent";
|
||||
import { useSidebarContext } from "@app/contexts/SidebarContext";
|
||||
import { useOptionalSidebarContext } from "@app/contexts/SidebarContext";
|
||||
import { useLogoAssets } from "@app/hooks/useLogoAssets";
|
||||
import styles from "@app/components/shared/tooltip/Tooltip.module.css";
|
||||
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from "@app/styles/zIndex";
|
||||
@@ -59,6 +59,29 @@ export interface TooltipProps {
|
||||
showCloseButton?: boolean;
|
||||
}
|
||||
|
||||
/** Split out so only tooltips with a header need the logo and the providers behind it. */
|
||||
function TooltipHeader({
|
||||
header,
|
||||
}: {
|
||||
header: NonNullable<TooltipProps["header"]>;
|
||||
}) {
|
||||
const { tooltipLogo } = useLogoAssets();
|
||||
return (
|
||||
<div className={styles["tooltip-header"]}>
|
||||
<div className={styles["tooltip-logo"]}>
|
||||
{header.logo || (
|
||||
<img
|
||||
src={tooltipLogo}
|
||||
alt="Stirling PDF"
|
||||
style={{ width: "1.4rem", height: "1.4rem", display: "block" }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<span className={styles["tooltip-title"]}>{header.title}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const Tooltip: React.FC<TooltipProps> = ({
|
||||
sidebarTooltip = false,
|
||||
position,
|
||||
@@ -85,7 +108,6 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
||||
const { t } = useTranslation();
|
||||
const [internalOpen, setInternalOpen] = useState(false);
|
||||
const [isPinned, setIsPinned] = useState(false);
|
||||
const { tooltipLogo } = useLogoAssets();
|
||||
|
||||
const triggerRef = useRef<HTMLElement | null>(null);
|
||||
const tooltipRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -105,9 +127,9 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
||||
}, []);
|
||||
|
||||
// Always call the hook unconditionally to satisfy React's rules of hooks.
|
||||
// The context is only used when sidebarTooltip is true.
|
||||
const sidebarContextValue = useSidebarContext();
|
||||
const sidebarContext = sidebarTooltip ? sidebarContextValue : null;
|
||||
// Optional: the plain tooltip renders outside the provider.
|
||||
const sidebarContextValue = useOptionalSidebarContext();
|
||||
const sidebarContext = sidebarTooltip ? (sidebarContextValue ?? null) : null;
|
||||
|
||||
const isControlled = controlledOpen !== undefined;
|
||||
const open = (isControlled ? !!controlledOpen : internalOpen) && !disabled;
|
||||
@@ -443,20 +465,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{header && (
|
||||
<div className={styles["tooltip-header"]}>
|
||||
<div className={styles["tooltip-logo"]}>
|
||||
{header.logo || (
|
||||
<img
|
||||
src={tooltipLogo}
|
||||
alt="Stirling PDF"
|
||||
style={{ width: "1.4rem", height: "1.4rem", display: "block" }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<span className={styles["tooltip-title"]}>{header.title}</span>
|
||||
</div>
|
||||
)}
|
||||
{header && <TooltipHeader header={header} />}
|
||||
<TooltipContent
|
||||
content={content}
|
||||
tips={tips}
|
||||
|
||||
@@ -14,13 +14,9 @@
|
||||
column-gap: 8px;
|
||||
min-height: 38px;
|
||||
padding: 0 8px;
|
||||
/* No left margin: the file sidebar's own 0.5rem padding already provides the
|
||||
gutter on that side, so adding one here would double it and leave the bar
|
||||
further from the sidebar than it is from the tool panel. */
|
||||
margin: var(--nav-gutter) var(--nav-gutter) 0 0;
|
||||
/* Flush, not a floating card: the same surface and hairline as the sidebars. */
|
||||
background-color: var(--c-surface);
|
||||
border: 1px solid var(--c-border-subtle);
|
||||
border-radius: var(--radius-nav);
|
||||
border-bottom: 1px solid var(--c-border-subtle);
|
||||
flex-shrink: 0;
|
||||
z-index: 50;
|
||||
}
|
||||
@@ -151,15 +147,18 @@
|
||||
/* ---- Tool buttons (own full-width row, always below the top row) ---- */
|
||||
.workbench-bar-center {
|
||||
order: 4;
|
||||
flex: 0 0 100%;
|
||||
/* Wider by the bar's paddings, cancelled by the negative margin, so the rule spans. */
|
||||
flex: 0 0 calc(100% + 16px);
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
max-width: calc(100% + 16px);
|
||||
margin-inline: -8px;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
/* Symmetric side padding leaves room for the retract handle pinned right
|
||||
without knocking the centred tool icons off-centre. */
|
||||
padding: 4px 36px;
|
||||
without knocking the centred tool icons off-centre. The extra 8px each side
|
||||
absorbs the negative margin. */
|
||||
padding: 4px 44px;
|
||||
border-top: 1px solid var(--c-border-subtle);
|
||||
}
|
||||
|
||||
@@ -327,6 +326,9 @@
|
||||
}
|
||||
|
||||
.workbench-bar-center {
|
||||
flex-basis: 100%;
|
||||
max-width: 100%;
|
||||
margin-inline: 0;
|
||||
padding: 2px 4px 2px 8px;
|
||||
}
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ import WorkbenchBarMobileActions from "@app/components/shared/workbenchBar/Workb
|
||||
import WorkbenchBarToolbarHandle from "@app/components/shared/workbenchBar/WorkbenchBarToolbarHandle";
|
||||
import { renderWithTooltip } from "@app/components/shared/workbenchBar/workbenchBarTooltip";
|
||||
import { WorkbenchBarActionsProps } from "@app/components/shared/workbenchBar/types";
|
||||
import { useIsMobile } from "@app/hooks/useIsMobile";
|
||||
import { useIsMobile, useIsPhone } from "@app/hooks/useIsMobile";
|
||||
import "@app/components/shared/WorkbenchBar.css";
|
||||
import { NotificationBell } from "@app/components/notifications/NotificationBell";
|
||||
|
||||
@@ -117,6 +117,8 @@ export default function WorkbenchBar({
|
||||
const { sharingEnabled } = useSharingEnabled();
|
||||
const viewerContext = React.useContext(ViewerContext);
|
||||
const isMobile = useIsMobile();
|
||||
// Below this width the rail, and the bell it carries, is hidden.
|
||||
const isPhone = useIsPhone();
|
||||
const [mobileToolsExpanded, setMobileToolsExpanded] = useState(false);
|
||||
|
||||
const selectors = useFileSelectors();
|
||||
@@ -487,7 +489,7 @@ export default function WorkbenchBar({
|
||||
data-wrapped="false"
|
||||
data-tour="workbench-bar"
|
||||
>
|
||||
{/* Left: optional "Back to My Files" + view switcher */}
|
||||
{/* Left: optional "Back to File library" + view switcher */}
|
||||
<div className="workbench-bar-views" data-tour="view-switcher">
|
||||
{returnRoute && hasFiles && (
|
||||
<>
|
||||
@@ -501,7 +503,7 @@ export default function WorkbenchBar({
|
||||
: "filesPage.backToMyFiles",
|
||||
returnRoute.label
|
||||
? `Back to ${returnRoute.label}`
|
||||
: "Back to My Files",
|
||||
: "Back to File library",
|
||||
{ folder: returnRoute.label ?? "" },
|
||||
)}
|
||||
leftSection={<ArrowBackIcon style={{ fontSize: "1.1rem" }} />}
|
||||
@@ -511,7 +513,7 @@ export default function WorkbenchBar({
|
||||
? t("filesPage.backToFolder", "Back to {{folder}}", {
|
||||
folder: returnRoute.label,
|
||||
})
|
||||
: t("filesPage.backToMyFiles", "Back to My Files")}
|
||||
: t("filesPage.backToMyFiles", "Back to File library")}
|
||||
</span>
|
||||
</Button>
|
||||
<div className="workbench-bar-divider" />
|
||||
@@ -603,9 +605,13 @@ export default function WorkbenchBar({
|
||||
enforcingProgress={enforcingProgress}
|
||||
/>
|
||||
)}
|
||||
{/* Last in the globals, so it is the rightmost control. */}
|
||||
<div className="workbench-bar-divider workbench-bar-globals-sep" />
|
||||
<NotificationBell />
|
||||
{isPhone && (
|
||||
<>
|
||||
{/* Last in the globals, so it is the rightmost control. */}
|
||||
<div className="workbench-bar-divider workbench-bar-globals-sep" />
|
||||
<NotificationBell />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -33,6 +33,8 @@ export interface NavFooterProps {
|
||||
otherApp?: NavFooterAppLink | null;
|
||||
/** Extra rows above the account row (the self-hosted link-account CTA). */
|
||||
accountExtras?: ReactNode;
|
||||
/** False where the rail owns the account control, so only one avatar is drawn. */
|
||||
showAccount?: boolean;
|
||||
/** Icon-rail state: labels collapse to tooltips. */
|
||||
collapsed?: boolean;
|
||||
className?: string;
|
||||
@@ -69,6 +71,7 @@ export function NavFooter({
|
||||
onOpenPlan,
|
||||
otherApp,
|
||||
accountExtras,
|
||||
showAccount = true,
|
||||
collapsed = false,
|
||||
className,
|
||||
}: NavFooterProps) {
|
||||
@@ -144,49 +147,51 @@ export function NavFooter({
|
||||
});
|
||||
}
|
||||
|
||||
rows.push({
|
||||
key: "account",
|
||||
node: (
|
||||
<Tooltip
|
||||
label={accountLabel}
|
||||
position="right"
|
||||
withinPortal
|
||||
disabled={!collapsed}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="nav-footer__row nav-footer__account"
|
||||
// Called with no args: handlers that take optional params (the
|
||||
// processor's openSettings(section?)) must not receive the event.
|
||||
onClick={onOpenSettings ? () => onOpenSettings() : undefined}
|
||||
disabled={!onOpenSettings}
|
||||
data-testid={onOpenSettings ? "config-button" : undefined}
|
||||
data-tour={onOpenSettings ? "config-button" : undefined}
|
||||
aria-label={accountLabel}
|
||||
if (showAccount) {
|
||||
rows.push({
|
||||
key: "account",
|
||||
node: (
|
||||
<Tooltip
|
||||
label={accountLabel}
|
||||
position="right"
|
||||
withinPortal
|
||||
disabled={!collapsed}
|
||||
>
|
||||
{/* Decorative: the button's own label already names the account, so
|
||||
an alt/label here would just repeat it to a screen reader. */}
|
||||
<span aria-hidden>
|
||||
<Avatar
|
||||
size="sm"
|
||||
name={displayName}
|
||||
src={profilePictureUrl ?? undefined}
|
||||
/>
|
||||
</span>
|
||||
{!collapsed && (
|
||||
<span className="nav-footer__row-label sidebar-content-fade">
|
||||
{displayName}
|
||||
<button
|
||||
type="button"
|
||||
className="nav-footer__row nav-footer__account"
|
||||
// Called with no args: a handler with an optional param must not get the event.
|
||||
onClick={onOpenSettings ? () => onOpenSettings() : undefined}
|
||||
disabled={!onOpenSettings}
|
||||
data-testid={onOpenSettings ? "config-button" : undefined}
|
||||
data-tour={onOpenSettings ? "config-button" : undefined}
|
||||
aria-label={accountLabel}
|
||||
>
|
||||
{/* Decorative: the button's own label already names the account. */}
|
||||
<span aria-hidden>
|
||||
<Avatar
|
||||
size="sm"
|
||||
name={displayName}
|
||||
src={profilePictureUrl ?? undefined}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
{onOpenSettings && !collapsed && (
|
||||
<span className="nav-footer__trailing" aria-hidden>
|
||||
<SettingsIcon sx={{ fontSize: "1.1rem" }} />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
),
|
||||
});
|
||||
{!collapsed && (
|
||||
<span className="nav-footer__row-label sidebar-content-fade">
|
||||
{displayName}
|
||||
</span>
|
||||
)}
|
||||
{onOpenSettings && !collapsed && (
|
||||
<span className="nav-footer__trailing" aria-hidden>
|
||||
<SettingsIcon sx={{ fontSize: "1.1rem" }} />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (rows.length === 0) return null;
|
||||
|
||||
return (
|
||||
<NavSurface
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Tooltip } from "@app/components/shared/Tooltip";
|
||||
import { BrandMark } from "@app/components/shared/BrandMark";
|
||||
|
||||
export interface QuickNavBrandProps {
|
||||
/** Returns the app you are in to its default state. */
|
||||
onReturnHome: () => void;
|
||||
}
|
||||
|
||||
export function QuickNavBrand({ onReturnHome }: QuickNavBrandProps) {
|
||||
const { t } = useTranslation();
|
||||
const label = t("quickNav.home", "Stirling");
|
||||
|
||||
return (
|
||||
<div className="quick-nav-brand">
|
||||
<Tooltip content={label} position="right" arrow>
|
||||
<button
|
||||
type="button"
|
||||
className="quick-nav-brand-button"
|
||||
aria-label={label}
|
||||
onClick={onReturnHome}
|
||||
>
|
||||
<BrandMark height="1.6rem" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useAccountIdentity } from "@app/hooks/useAccountIdentity";
|
||||
import {
|
||||
NotificationPanel,
|
||||
NOTIFICATIONS_PANEL_ID,
|
||||
} from "@app/components/notifications/NotificationPanel";
|
||||
import { useNotificationActions } from "@app/components/notifications/notificationActions";
|
||||
import { useQuickNavToolReasons } from "@app/components/shared/quickNav/useQuickNavToolReasons";
|
||||
import { useNotificationsAvailable } from "@app/components/notifications/useNotificationsAvailable";
|
||||
import { useSigningBadgeCount } from "@app/hooks/signing/useSigningBadgeCount";
|
||||
import {
|
||||
useRegisterQuickNavHost,
|
||||
type QuickNavToolReasons,
|
||||
} from "@app/contexts/QuickNavHostContext";
|
||||
import type { ToolId } from "@app/types/toolId";
|
||||
|
||||
export interface QuickNavHostBridgeProps {
|
||||
portalAccess?: boolean;
|
||||
readerMode?: boolean;
|
||||
onSetReaderMode?: (on: boolean) => void;
|
||||
onOpenSettings: () => void;
|
||||
requestNavigation?: (go: () => void) => void;
|
||||
onGoToDefaultState?: () => void;
|
||||
onSelectTool?: (toolId: ToolId) => void;
|
||||
/** Merged over the reasons worked out here, for what only the app can see. */
|
||||
toolReasons?: QuickNavToolReasons;
|
||||
}
|
||||
|
||||
/** Registers with the rail what only the app can see, and owns the notifications panel. */
|
||||
export function QuickNavHostBridge({
|
||||
portalAccess = false,
|
||||
readerMode = false,
|
||||
onSetReaderMode,
|
||||
onOpenSettings,
|
||||
requestNavigation,
|
||||
onSelectTool,
|
||||
onGoToDefaultState,
|
||||
toolReasons,
|
||||
}: QuickNavHostBridgeProps) {
|
||||
const { displayName, profilePictureUrl } = useAccountIdentity();
|
||||
const signingBadge = useSigningBadgeCount();
|
||||
const notificationsAvailable = useNotificationsAvailable();
|
||||
// Built even when closed: it carries a one-shot document pickup that would sit unclaimed.
|
||||
const notificationActions = useNotificationActions();
|
||||
const endpointReasons = useQuickNavToolReasons();
|
||||
const mergedToolReasons = useMemo(() => {
|
||||
// An empty map from the app is silence, not an answer.
|
||||
const extra =
|
||||
toolReasons && Object.keys(toolReasons).length > 0 ? toolReasons : null;
|
||||
if (!endpointReasons && !extra) return undefined;
|
||||
return { ...endpointReasons, ...extra };
|
||||
}, [endpointReasons, toolReasons]);
|
||||
const [notificationsOpen, setNotificationsOpen] = useState(false);
|
||||
const closeNotifications = useCallback(() => setNotificationsOpen(false), []);
|
||||
|
||||
useRegisterQuickNavHost(
|
||||
{
|
||||
identity: { displayName, profilePictureUrl },
|
||||
signingBadge,
|
||||
portalAccess,
|
||||
readerMode,
|
||||
notificationsOpen,
|
||||
toolReasons: mergedToolReasons,
|
||||
},
|
||||
{
|
||||
openSettings: onOpenSettings,
|
||||
requestNavigation,
|
||||
selectTool: onSelectTool,
|
||||
setReaderMode: onSetReaderMode,
|
||||
goToDefaultState: onGoToDefaultState,
|
||||
toggleNotifications: () => setNotificationsOpen((open) => !open),
|
||||
},
|
||||
);
|
||||
|
||||
// Mounted only while open, so a closed panel never subscribes to the poll.
|
||||
if (!notificationsAvailable || !notificationsOpen) return null;
|
||||
return (
|
||||
<NotificationPanel
|
||||
id={NOTIFICATIONS_PANEL_ID}
|
||||
onClose={closeNotifications}
|
||||
registry={notificationActions}
|
||||
className="notification-bell__panel--rail"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
/* ========== QUICK NAV RAIL ========== */
|
||||
|
||||
.quick-nav-rail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.quick-nav-rail-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--quicknav-item-gap);
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* The glyph stays at the sidebar's scale; the button fills the rail for hit area. */
|
||||
.quick-nav-rail-item {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 2.25rem;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: var(--c-text-subtle);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color var(--motion-fast),
|
||||
color var(--motion-fast);
|
||||
}
|
||||
|
||||
.quick-nav-rail-item svg,
|
||||
.quick-nav-rail-item img {
|
||||
width: 1.125rem;
|
||||
height: 1.125rem;
|
||||
color: inherit;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
/* Taller than wide (71x79), so height drives the width. */
|
||||
.quick-nav-rail-item .sui-brandmark {
|
||||
width: auto;
|
||||
height: 1.125rem;
|
||||
}
|
||||
|
||||
.quick-nav-rail-item[aria-disabled="true"] .sui-brandmark,
|
||||
.quick-nav-rail-item[aria-disabled="true"] svg[viewBox="0 0 256 256"] {
|
||||
filter: grayscale(1);
|
||||
}
|
||||
|
||||
.quick-nav-rail-item:hover {
|
||||
background: var(--c-hover);
|
||||
color: var(--c-text);
|
||||
}
|
||||
|
||||
/* Opacity rather than a colour: there is no disabled-text token. */
|
||||
.quick-nav-rail-item[aria-disabled="true"] {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.quick-nav-rail-item[aria-disabled="true"]:hover {
|
||||
background: transparent;
|
||||
color: var(--c-primary);
|
||||
}
|
||||
|
||||
.quick-nav-rail-item:focus-visible {
|
||||
outline: 0.125rem solid var(--c-primary);
|
||||
outline-offset: -0.125rem;
|
||||
}
|
||||
|
||||
.quick-nav-rail-badge {
|
||||
position: absolute;
|
||||
top: 0.125rem;
|
||||
inset-inline-end: 0.125rem;
|
||||
min-width: 0.875rem;
|
||||
height: 0.875rem;
|
||||
padding: 0 0.1875rem;
|
||||
border-radius: var(--radius-pill);
|
||||
/* The solid step: 9px numerals need the darker end of the ramp. */
|
||||
background: var(--c-danger-solid);
|
||||
color: var(--c-text-on-primary);
|
||||
font-size: 0.5625rem;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
line-height: 0.875rem;
|
||||
text-align: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.quick-nav-rail-badge[data-tone="warning"] {
|
||||
background: var(--c-warning-solid);
|
||||
}
|
||||
|
||||
/* Top margin only: the group below supplies the other half, centring the rule. */
|
||||
.quick-nav-rail-divider {
|
||||
width: 100%;
|
||||
height: 0;
|
||||
margin: var(--quicknav-item-gap) 0 0;
|
||||
border: 0;
|
||||
border-top: 1px solid var(--c-border);
|
||||
}
|
||||
|
||||
.quick-nav-rail-footer {
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--quicknav-item-gap);
|
||||
width: 100%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ---- Brand: one header row tall, so it lines up with the sidebar's wordmark ---- */
|
||||
.quick-nav-brand {
|
||||
width: 100%;
|
||||
height: var(--nav-header-h);
|
||||
flex-shrink: 0;
|
||||
/* The bar's inset supplies part of the gap; only the remainder is added here. */
|
||||
margin-bottom: calc(var(--quicknav-item-gap) - var(--quicknav-surface-pad));
|
||||
}
|
||||
|
||||
.quick-nav-brand-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.quick-nav-brand-button:focus-visible {
|
||||
outline: 0.125rem solid var(--c-primary);
|
||||
outline-offset: -0.125rem;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
/* The "on" state: a solid block with the glyph knocked out, hover owning the tints. */
|
||||
.quick-nav-rail-item[aria-current="true"],
|
||||
.quick-nav-rail-item[aria-current="true"]:hover,
|
||||
.quick-nav-rail-item[aria-pressed="true"],
|
||||
.quick-nav-rail-item[aria-pressed="true"]:hover {
|
||||
background: var(--c-text);
|
||||
color: var(--c-surface);
|
||||
}
|
||||
|
||||
/* On a dark ground full ink is white, so mix the block back toward the surface. */
|
||||
[data-theme="dark"] .quick-nav-rail-item[aria-current="true"],
|
||||
[data-theme="dark"] .quick-nav-rail-item[aria-current="true"]:hover,
|
||||
[data-theme="dark"] .quick-nav-rail-item[aria-pressed="true"],
|
||||
[data-theme="dark"] .quick-nav-rail-item[aria-pressed="true"]:hover,
|
||||
html[data-app-theme="midnight"] .quick-nav-rail-item[aria-current="true"],
|
||||
html[data-app-theme="midnight"] .quick-nav-rail-item[aria-current="true"]:hover,
|
||||
html[data-app-theme="midnight"] .quick-nav-rail-item[aria-pressed="true"],
|
||||
html[data-app-theme="midnight"] .quick-nav-rail-item[aria-pressed="true"]:hover,
|
||||
[data-mantine-color-scheme="dark"] .quick-nav-rail-item[aria-current="true"],
|
||||
[data-mantine-color-scheme="dark"]
|
||||
.quick-nav-rail-item[aria-current="true"]:hover,
|
||||
[data-mantine-color-scheme="dark"] .quick-nav-rail-item[aria-pressed="true"],
|
||||
[data-mantine-color-scheme="dark"]
|
||||
.quick-nav-rail-item[aria-pressed="true"]:hover {
|
||||
background: color-mix(in srgb, var(--c-text) 80%, var(--c-surface));
|
||||
color: var(--c-surface);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/* The account control, pinned to the bottom of the bar. */
|
||||
|
||||
.quick-nav-rail-account {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
flex-shrink: 0;
|
||||
/* Further than the shortcut gap: a filled disc reads heavier than a line glyph. */
|
||||
margin-top: var(--space-2);
|
||||
/* Matches the slack centring the brand mark leaves at the top. */
|
||||
padding-bottom: 0.3125rem;
|
||||
}
|
||||
|
||||
.quick-nav-rail-avatar-target {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
/* Appearance comes from the shared Avatar; only the button reset is ours. */
|
||||
.quick-nav-rail-avatar {
|
||||
border: none;
|
||||
padding: 0;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.quick-nav-rail-avatar:focus-visible {
|
||||
outline: 0.125rem solid var(--c-primary);
|
||||
outline-offset: 0.125rem;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Tooltip } from "@app/components/shared/Tooltip";
|
||||
import { Avatar } from "@app/ui/Avatar";
|
||||
import type { QuickNavIdentity } from "@app/contexts/QuickNavHostContext";
|
||||
import "@app/components/shared/quickNav/QuickNavRailAccount.css";
|
||||
|
||||
export interface QuickNavRailAccountProps {
|
||||
onOpenSettings: () => void;
|
||||
/** Null between apps; the disc still renders, so the bar keeps its shape. */
|
||||
identity: QuickNavIdentity | null;
|
||||
}
|
||||
|
||||
/** The avatar opens settings, so there is no separate gear beside it. */
|
||||
export function QuickNavRailAccount({
|
||||
onOpenSettings,
|
||||
identity,
|
||||
}: QuickNavRailAccountProps) {
|
||||
const { t } = useTranslation();
|
||||
const displayName =
|
||||
identity?.displayName ?? t("auth.displayName.user", "User");
|
||||
const profilePictureUrl = identity?.profilePictureUrl ?? null;
|
||||
const label = `${displayName} — ${t("fileSidebar.openSettings", "Open settings")}`;
|
||||
|
||||
return (
|
||||
<div className="quick-nav-rail-account">
|
||||
<Tooltip content={label} position="right" arrow>
|
||||
{/* A span, not the Avatar: Tooltip binds by cloning its child. */}
|
||||
<span
|
||||
className="quick-nav-rail-avatar-target"
|
||||
data-testid="config-button"
|
||||
data-tour="config-button"
|
||||
>
|
||||
<Avatar
|
||||
src={profilePictureUrl ?? undefined}
|
||||
name={displayName}
|
||||
size="sm"
|
||||
onClick={onOpenSettings}
|
||||
ariaLabel={label}
|
||||
className="quick-nav-rail-avatar"
|
||||
/>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render } from "@testing-library/react";
|
||||
import {
|
||||
QuickNavRailBase,
|
||||
type QuickNavEntry,
|
||||
} from "@app/components/shared/quickNav/QuickNavRailBase";
|
||||
|
||||
/** The rail needs no providers. */
|
||||
function withProviders(ui: React.ReactNode) {
|
||||
return <>{ui}</>;
|
||||
}
|
||||
|
||||
function entry(
|
||||
id: string,
|
||||
overrides: Partial<QuickNavEntry> = {},
|
||||
): QuickNavEntry {
|
||||
return {
|
||||
id,
|
||||
label: id,
|
||||
icon: null,
|
||||
onClick: () => {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const PROCESSOR = entry("processor");
|
||||
const WITHIN = [entry("files"), entry("reader")];
|
||||
|
||||
function renderRail(groups: QuickNavEntry[][]) {
|
||||
const { container } = render(
|
||||
withProviders(<QuickNavRailBase groups={groups} />),
|
||||
);
|
||||
return {
|
||||
labels: [...container.querySelectorAll(".quick-nav-rail-item")].map((b) =>
|
||||
b.getAttribute("aria-label"),
|
||||
),
|
||||
dividers: container.querySelectorAll(".quick-nav-rail-divider").length,
|
||||
};
|
||||
}
|
||||
|
||||
describe("QuickNavRailBase — groups", () => {
|
||||
it("divides one group from the next", () => {
|
||||
const { labels, dividers } = renderRail([[PROCESSOR], WITHIN]);
|
||||
|
||||
expect(labels).toEqual(["processor", "files", "reader"]);
|
||||
expect(dividers).toBe(1);
|
||||
});
|
||||
|
||||
it("drops an empty group, and the divider with it", () => {
|
||||
const { labels, dividers } = renderRail([[], WITHIN]);
|
||||
|
||||
expect(labels).toEqual(["files", "reader"]);
|
||||
expect(dividers).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("QuickNavRailBase — entry state", () => {
|
||||
it("reports on/off for a toggle and nothing for the rest", () => {
|
||||
// Nothing here is a view you occupy, so only a real toggle has state.
|
||||
const { container } = render(
|
||||
withProviders(
|
||||
<QuickNavRailBase
|
||||
groups={[
|
||||
[PROCESSOR],
|
||||
[entry("reader", { pressed: true }), entry("files")],
|
||||
]}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
|
||||
const state = [...container.querySelectorAll(".quick-nav-rail-item")].map(
|
||||
(b) => [b.getAttribute("aria-label"), b.getAttribute("aria-pressed")],
|
||||
);
|
||||
expect(state).toEqual([
|
||||
["processor", null],
|
||||
["reader", "true"],
|
||||
["files", null],
|
||||
]);
|
||||
expect(container.querySelectorAll("[aria-current]")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("keeps a disabled entry in the tab order so its reason stays reachable", () => {
|
||||
// The tooltip carrying the reason is only reachable while it can be focused.
|
||||
const { container } = render(
|
||||
withProviders(
|
||||
<QuickNavRailBase
|
||||
groups={[
|
||||
[
|
||||
entry("automate", {
|
||||
disabled: true,
|
||||
reason: "Disabled by server administrator",
|
||||
}),
|
||||
],
|
||||
]}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
|
||||
const automate = container.querySelector('[aria-label="automate"]')!;
|
||||
expect(automate.getAttribute("aria-disabled")).toBe("true");
|
||||
expect(automate.hasAttribute("disabled")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps an unavailable entry rendered, disabled rather than dropped", () => {
|
||||
// Slots must not appear and vanish as access resolves.
|
||||
const { container } = render(
|
||||
withProviders(
|
||||
<QuickNavRailBase
|
||||
groups={[
|
||||
[entry("processor", { disabled: true, reason: "no access" })],
|
||||
WITHIN,
|
||||
]}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
|
||||
const processor = container.querySelector('[aria-label="processor"]');
|
||||
expect(processor).not.toBeNull();
|
||||
expect(processor?.getAttribute("aria-disabled")).toBe("true");
|
||||
// aria-disabled, not the disabled attribute: it stays focusable for its tooltip.
|
||||
expect(processor?.hasAttribute("disabled")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Tooltip } from "@app/components/shared/Tooltip";
|
||||
import "@app/components/shared/quickNav/QuickNavRail.css";
|
||||
|
||||
export type QuickNavTarget = "reader" | "editor" | "files" | "processor";
|
||||
|
||||
export interface QuickNavEntry {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: ReactNode;
|
||||
/** The app you are in, drawn with an edge bar. */
|
||||
current?: boolean;
|
||||
/** Only for entries that toggle something; use `current` for the app you are in. */
|
||||
pressed?: boolean;
|
||||
/** Inert, with `reason` as its tooltip. Entries are dimmed, never dropped. */
|
||||
disabled?: boolean;
|
||||
reason?: string;
|
||||
badge?: number;
|
||||
/** Popup semantics for an entry whose panel is rendered in another tree. */
|
||||
expanded?: boolean;
|
||||
controls?: string;
|
||||
/** "danger" waits on the user; "warning" is awareness only. */
|
||||
badgeTone?: "danger" | "warning";
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export interface QuickNavRailBaseProps {
|
||||
/** Divided by a rule; empty groups are dropped. */
|
||||
groups: QuickNavEntry[][];
|
||||
footer?: ReactNode;
|
||||
}
|
||||
|
||||
/** Exported so footer entries reuse it rather than a lookalike. */
|
||||
export function RailButton({
|
||||
label,
|
||||
icon,
|
||||
pressed,
|
||||
disabled,
|
||||
reason,
|
||||
badge,
|
||||
badgeTone = "danger",
|
||||
current,
|
||||
expanded,
|
||||
controls,
|
||||
onClick,
|
||||
}: Omit<QuickNavEntry, "id">) {
|
||||
return (
|
||||
<Tooltip
|
||||
content={disabled && reason ? `${label} — ${reason}` : label}
|
||||
position="right"
|
||||
arrow
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="quick-nav-rail-item"
|
||||
aria-pressed={pressed}
|
||||
aria-current={current ? "true" : undefined}
|
||||
aria-label={label}
|
||||
aria-haspopup={expanded === undefined ? undefined : "dialog"}
|
||||
aria-expanded={expanded}
|
||||
aria-controls={expanded === undefined ? undefined : controls}
|
||||
// aria-disabled, not `disabled`: stays focusable, so its tooltip is reachable.
|
||||
aria-disabled={disabled || undefined}
|
||||
onClick={disabled ? undefined : onClick}
|
||||
>
|
||||
{icon}
|
||||
{badge !== undefined && badge > 0 && (
|
||||
<span
|
||||
className="quick-nav-rail-badge"
|
||||
data-tone={badgeTone}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{badge > 9 ? "9+" : badge}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export function QuickNavRailBase({ groups, footer }: QuickNavRailBaseProps) {
|
||||
const { t } = useTranslation();
|
||||
const populated = groups.filter((entries) => entries.length > 0);
|
||||
return (
|
||||
<nav
|
||||
className="quick-nav-rail"
|
||||
aria-label={t("quickNav.landmark", "Quick navigation")}
|
||||
>
|
||||
{populated.map((group, index) => (
|
||||
<div className="quick-nav-rail-group" key={group[0].id}>
|
||||
{index > 0 && <hr className="quick-nav-rail-divider" />}
|
||||
{group.map((entry) => (
|
||||
<RailButton key={entry.id} {...entry} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
{footer}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
.quick-nav-rail-container {
|
||||
/* On the column, so the parts outside the nav inherit them too. */
|
||||
--quicknav-item-gap: var(--space-3);
|
||||
--quicknav-surface-pad: 0.375rem;
|
||||
|
||||
width: var(--nav-rail-w);
|
||||
height: 100%;
|
||||
flex-shrink: 0;
|
||||
box-sizing: border-box;
|
||||
/* On the column, not the bar, so the fill covers the gutters too. */
|
||||
background-color: var(--c-surface);
|
||||
border-inline-end: 1px solid var(--c-border-subtle);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-block: var(--nav-gutter);
|
||||
padding-inline: calc(var(--nav-gutter) / 2);
|
||||
}
|
||||
|
||||
/* Child selector to beat .sui-nav-surface, which would win on order. */
|
||||
.quick-nav-rail-container > .quick-nav-rail-surface {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.quick-nav-rail-surface {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
/* No inline padding: the bar is one target wide and would squeeze the buttons. */
|
||||
padding: var(--quicknav-surface-pad) 0;
|
||||
}
|
||||
|
||||
/* Below this width the sidebar is an off-canvas drawer, and the rail is just noise. */
|
||||
@media (max-width: 48rem) {
|
||||
.quick-nav-rail-container {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { NavSurface } from "@app/ui/NavSurface";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import { QuickNavBrand } from "@app/components/shared/quickNav/QuickNavBrand";
|
||||
import type { QuickNavIdentity } from "@app/contexts/QuickNavHostContext";
|
||||
import {
|
||||
QuickNavRailBase,
|
||||
RailButton,
|
||||
type QuickNavRailBaseProps,
|
||||
} from "@app/components/shared/quickNav/QuickNavRailBase";
|
||||
import { QuickNavRailAccount } from "@app/components/shared/quickNav/QuickNavRailAccount";
|
||||
import { QuickNavRailNotifications } from "@app/components/shared/quickNav/QuickNavRailNotifications";
|
||||
import "@app/components/shared/quickNav/QuickNavRailContainer.css";
|
||||
|
||||
export type {
|
||||
QuickNavEntry,
|
||||
QuickNavTarget,
|
||||
} from "@app/components/shared/quickNav/QuickNavRailBase";
|
||||
|
||||
export interface QuickNavRailContainerProps extends Omit<
|
||||
QuickNavRailBaseProps,
|
||||
"footer"
|
||||
> {
|
||||
/** The rail owns the account control, so the sidebars drop their own row. */
|
||||
onOpenSettings?: () => void;
|
||||
/** Omitted in builds with no processor to invite anyone into. */
|
||||
onInvite?: () => void;
|
||||
onToggleNotifications?: () => void;
|
||||
notificationsOpen?: boolean;
|
||||
identity?: QuickNavIdentity | null;
|
||||
onReturnHome: () => void;
|
||||
}
|
||||
|
||||
/** The fixed-width column the rail sits in. */
|
||||
export function QuickNavRailContainer({
|
||||
onOpenSettings,
|
||||
onInvite,
|
||||
onToggleNotifications,
|
||||
notificationsOpen,
|
||||
identity = null,
|
||||
onReturnHome,
|
||||
...railProps
|
||||
}: QuickNavRailContainerProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="quick-nav-rail-container">
|
||||
<QuickNavBrand onReturnHome={onReturnHome} />
|
||||
<NavSurface className="quick-nav-rail-surface">
|
||||
<QuickNavRailBase
|
||||
{...railProps}
|
||||
footer={
|
||||
// Always rendered: the bell lives here too, so gating the footer hides it.
|
||||
<div className="quick-nav-rail-footer">
|
||||
<QuickNavRailNotifications
|
||||
onToggle={onToggleNotifications}
|
||||
open={notificationsOpen}
|
||||
/>
|
||||
{onInvite && (
|
||||
<RailButton
|
||||
label={t("quickNav.invite", "Invite")}
|
||||
icon={
|
||||
<LocalIcon
|
||||
icon="person-add-outline-rounded"
|
||||
width="1.125rem"
|
||||
height="1.125rem"
|
||||
/>
|
||||
}
|
||||
onClick={onInvite}
|
||||
/>
|
||||
)}
|
||||
{onOpenSettings && (
|
||||
<QuickNavRailAccount
|
||||
onOpenSettings={onOpenSettings}
|
||||
identity={identity}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</NavSurface>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import { QuickNavRailContainer } from "@app/components/shared/quickNav/QuickNavRailContainer";
|
||||
import type { QuickNavEntry } from "@app/components/shared/quickNav/QuickNavRailBase";
|
||||
import type { ToolId } from "@app/types/toolId";
|
||||
import { useQuickNavHost } from "@app/contexts/QuickNavHostContext";
|
||||
import { requestReaderMode } from "@app/utils/pendingReaderMode";
|
||||
import {
|
||||
saveEditorReturnPath,
|
||||
takeEditorReturnPath,
|
||||
} from "@app/services/workbenchSession";
|
||||
import { EDITOR_BASENAME } from "@app/routes/editorBasename";
|
||||
import { PORTAL_BASENAME } from "@app/routes/portalBasename";
|
||||
import { HAS_PORTAL } from "@app/routes/hasPortal";
|
||||
|
||||
const SIZE = "1.125rem";
|
||||
|
||||
/** Entries come from the URL, not either app's context, so the rail survives a switch. */
|
||||
export function QuickNavRailHost() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { pathname } = useLocation();
|
||||
const host = useQuickNavHost();
|
||||
|
||||
const appMounted = Boolean(host?.appMounted);
|
||||
|
||||
const inPortal = pathname.startsWith(PORTAL_BASENAME);
|
||||
|
||||
// Only the app knows its own default state.
|
||||
const returnHome = () => {
|
||||
const reset = host?.actions.current?.goToDefaultState;
|
||||
if (reset) reset();
|
||||
else navigate(inPortal ? PORTAL_BASENAME : EDITOR_BASENAME);
|
||||
};
|
||||
|
||||
// Guarded where the app supplies a guard, so leaving mid-edit still prompts.
|
||||
const go = (to: string) => {
|
||||
const guard = host?.actions.current?.requestNavigation;
|
||||
if (guard) guard(() => navigate(to));
|
||||
else navigate(to);
|
||||
};
|
||||
|
||||
// Through the app where possible: its route only selects a tool on a fresh mount.
|
||||
const openTool = (toolId: ToolId, route: string) => {
|
||||
const select = host?.actions.current?.selectTool;
|
||||
if (select) select(toolId);
|
||||
else go(route);
|
||||
};
|
||||
|
||||
const unusable = (id: ToolId) => {
|
||||
const reason = host?.toolReasons?.[id];
|
||||
return { disabled: Boolean(reason), reason };
|
||||
};
|
||||
|
||||
const apps: QuickNavEntry[] = [
|
||||
{
|
||||
id: "processor",
|
||||
label: t("quickNav.processor", "Processor"),
|
||||
// Two literals, not a computed name: the offline icon bundle scans for `icon="..."`.
|
||||
icon: inPortal ? (
|
||||
<LocalIcon icon="memory-rounded" width={SIZE} height={SIZE} />
|
||||
) : (
|
||||
<LocalIcon icon="memory-outline-rounded" width={SIZE} height={SIZE} />
|
||||
),
|
||||
current: inPortal,
|
||||
disabled: HAS_PORTAL && !inPortal && !host?.portalAccess,
|
||||
reason:
|
||||
HAS_PORTAL && !inPortal && !host?.portalAccess
|
||||
? t("quickNav.noProcessorAccess", "Ask an admin for processor access")
|
||||
: undefined,
|
||||
onClick: () => {
|
||||
if (inPortal) {
|
||||
returnHome();
|
||||
return;
|
||||
}
|
||||
saveEditorReturnPath();
|
||||
go(PORTAL_BASENAME);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "editor",
|
||||
label: t("quickNav.editor", "Editor"),
|
||||
icon: inPortal ? (
|
||||
<LocalIcon icon="edit-outline-rounded" width={SIZE} height={SIZE} />
|
||||
) : (
|
||||
<LocalIcon icon="edit-rounded" width={SIZE} height={SIZE} />
|
||||
),
|
||||
current: !inPortal,
|
||||
onClick: () => {
|
||||
if (!inPortal) {
|
||||
returnHome();
|
||||
return;
|
||||
}
|
||||
// Back to where you left the editor, not its front door.
|
||||
navigate(takeEditorReturnPath() ?? EDITOR_BASENAME);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const within: QuickNavEntry[] = [
|
||||
{
|
||||
id: "files",
|
||||
label: t("fileSidebar.myFiles", "File library"),
|
||||
icon: (
|
||||
<LocalIcon icon="folder-outline-rounded" width={SIZE} height={SIZE} />
|
||||
),
|
||||
onClick: () => go("/files"),
|
||||
},
|
||||
{
|
||||
id: "reader",
|
||||
label: t("quickNav.reader", "Reader"),
|
||||
icon: (
|
||||
<LocalIcon
|
||||
icon="menu-book-outline-rounded"
|
||||
width={SIZE}
|
||||
height={SIZE}
|
||||
/>
|
||||
),
|
||||
pressed: Boolean(host?.readerMode),
|
||||
// From the processor there is no editor to toggle - see pendingReaderMode.
|
||||
onClick: () => {
|
||||
const setMode = host?.actions.current?.setReaderMode;
|
||||
if (setMode) {
|
||||
setMode(!host?.readerMode);
|
||||
return;
|
||||
}
|
||||
requestReaderMode();
|
||||
go(EDITOR_BASENAME);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "automate",
|
||||
label: t("quickAccess.automate", "Automate"),
|
||||
icon: (
|
||||
<LocalIcon icon="rebase-outline-rounded" width={SIZE} height={SIZE} />
|
||||
),
|
||||
...unusable("automate"),
|
||||
onClick: () => openTool("automate", "/automate"),
|
||||
},
|
||||
{
|
||||
id: "sharedSign",
|
||||
label: t("home.sharedSign.title", "Shared Signing"),
|
||||
icon: (
|
||||
<LocalIcon icon="draw-outline-rounded" width={SIZE} height={SIZE} />
|
||||
),
|
||||
badge: host?.signingBadge,
|
||||
badgeTone: "warning",
|
||||
...unusable("sharedSign"),
|
||||
onClick: () => openTool("sharedSign", "/shared-sign"),
|
||||
},
|
||||
];
|
||||
|
||||
// Read at click time, so it's always the mounted app's.
|
||||
const openSettings = () => host?.actions.current?.openSettings?.();
|
||||
|
||||
// A route that isn't the app hides the bar - see useSuppressQuickNavRail.
|
||||
if (!appMounted || host?.chromeless) return null;
|
||||
|
||||
return (
|
||||
<QuickNavRailContainer
|
||||
groups={HAS_PORTAL ? [apps, within] : [within]}
|
||||
onReturnHome={returnHome}
|
||||
identity={host?.identity ?? null}
|
||||
onOpenSettings={host?.hasSettings ? openSettings : undefined}
|
||||
onInvite={
|
||||
// Spelt out: VIEW_PATHS lives in the portal, which core cannot import.
|
||||
HAS_PORTAL && host?.portalAccess
|
||||
? () => go(`${PORTAL_BASENAME}/users`)
|
||||
: undefined
|
||||
}
|
||||
onToggleNotifications={() =>
|
||||
host?.actions.current?.toggleNotifications?.()
|
||||
}
|
||||
notificationsOpen={host?.notificationsOpen}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import type { AppNotification } from "@app/services/notifications";
|
||||
import { QuickNavRailNotifications } from "@app/components/shared/quickNav/QuickNavRailNotifications";
|
||||
|
||||
const fetchNotifications = vi.fn();
|
||||
|
||||
vi.mock("@app/services/notifications", () => ({
|
||||
fetchNotifications: (...args: unknown[]) => fetchNotifications(...args),
|
||||
}));
|
||||
|
||||
vi.mock("@app/services/localFilePresence", () => ({
|
||||
hasLocalFile: () => Promise.resolve(false),
|
||||
}));
|
||||
|
||||
const h = vi.hoisted(() => ({ notificationsAvailable: true }));
|
||||
|
||||
vi.mock("@app/components/notifications/useNotificationsAvailable", () => ({
|
||||
useNotificationsAvailable: () => h.notificationsAvailable,
|
||||
}));
|
||||
|
||||
function notification(id: string): AppNotification {
|
||||
return {
|
||||
id,
|
||||
kind: "PIPELINE_FAILED",
|
||||
title: id,
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
fileId: null,
|
||||
sourceId: null,
|
||||
count: 1,
|
||||
actions: [],
|
||||
} as unknown as AppNotification;
|
||||
}
|
||||
|
||||
describe("QuickNavRailNotifications", () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
fetchNotifications.mockReset().mockResolvedValue([]);
|
||||
h.notificationsAvailable = true;
|
||||
});
|
||||
|
||||
it("keeps out of a build with no notifications API, and off its timer", async () => {
|
||||
// No endpoint to poll and nothing it could show.
|
||||
h.notificationsAvailable = false;
|
||||
|
||||
const { container } = render(
|
||||
<QuickNavRailNotifications onToggle={() => {}} />,
|
||||
);
|
||||
|
||||
await Promise.resolve();
|
||||
expect(container.querySelector(".quick-nav-rail-item")).toBeNull();
|
||||
expect(fetchNotifications).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("carries the unread count on the icon", async () => {
|
||||
fetchNotifications.mockResolvedValue([
|
||||
notification("a"),
|
||||
notification("b"),
|
||||
]);
|
||||
|
||||
render(<QuickNavRailNotifications onToggle={() => {}} />);
|
||||
|
||||
expect(await screen.findByText("2")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("asks the mounted app to open the panel rather than opening one itself", async () => {
|
||||
const onToggle = vi.fn();
|
||||
const { container } = render(
|
||||
<QuickNavRailNotifications onToggle={onToggle} />,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(fetchNotifications).toHaveBeenCalled());
|
||||
fireEvent.click(container.querySelector(".quick-nav-rail-item")!);
|
||||
|
||||
expect(onToggle).toHaveBeenCalledTimes(1);
|
||||
// No panel of its own: a row's actions would have no workbench to act on.
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("stays pressable before an app has registered, doing nothing", async () => {
|
||||
// Between apps there is briefly no handler.
|
||||
const { container } = render(<QuickNavRailNotifications />);
|
||||
|
||||
await waitFor(() => expect(fetchNotifications).toHaveBeenCalled());
|
||||
const button = container.querySelector(".quick-nav-rail-item")!;
|
||||
expect(() => fireEvent.click(button)).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import { RailButton } from "@app/components/shared/quickNav/QuickNavRailBase";
|
||||
import { useNotifications } from "@app/hooks/useNotifications";
|
||||
import { useNotificationsAvailable } from "@app/components/notifications/useNotificationsAvailable";
|
||||
import { NOTIFICATIONS_PANEL_ID } from "@app/components/notifications/NotificationPanel";
|
||||
|
||||
export interface QuickNavRailNotificationsProps {
|
||||
onToggle?: () => void;
|
||||
/** Whether the app's panel is open, which this button reports but does not own. */
|
||||
open?: boolean;
|
||||
}
|
||||
|
||||
/** The count is read here; the app owns the panel - see NotificationPanel. */
|
||||
export function QuickNavRailNotifications({
|
||||
onToggle,
|
||||
open = false,
|
||||
}: QuickNavRailNotificationsProps) {
|
||||
// Gated before the count is read: subscribing starts the poll.
|
||||
const available = useNotificationsAvailable();
|
||||
if (!available) return null;
|
||||
return <MountedRailNotifications onToggle={onToggle} open={open} />;
|
||||
}
|
||||
|
||||
function MountedRailNotifications({
|
||||
onToggle,
|
||||
open,
|
||||
}: QuickNavRailNotificationsProps) {
|
||||
const { t } = useTranslation();
|
||||
const { unreadCount } = useNotifications();
|
||||
|
||||
return (
|
||||
// Read by the panel's outside-click handler; on a wrapper, RailButton's props being fixed.
|
||||
<span data-notifications-trigger>
|
||||
<RailButton
|
||||
label={t("quickNav.notifications", "Notifications")}
|
||||
icon={
|
||||
<LocalIcon
|
||||
icon="notifications-outline-rounded"
|
||||
width="1.125rem"
|
||||
height="1.125rem"
|
||||
/>
|
||||
}
|
||||
badge={unreadCount}
|
||||
expanded={Boolean(open)}
|
||||
controls={NOTIFICATIONS_PANEL_ID}
|
||||
onClick={() => onToggle?.()}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { useQuickNavToolReasons } from "@app/components/shared/quickNav/useQuickNavToolReasons";
|
||||
|
||||
const h = vi.hoisted(() => ({
|
||||
endpointStatus: {} as Record<string, boolean>,
|
||||
endpointDetails: {} as Record<string, { reason?: string }>,
|
||||
loading: false,
|
||||
configLoading: false,
|
||||
groupSigningEnabled: true,
|
||||
}));
|
||||
|
||||
vi.mock("@app/hooks/useEndpointConfig", () => ({
|
||||
useMultipleEndpointsEnabled: () => ({
|
||||
endpointStatus: h.endpointStatus,
|
||||
endpointDetails: h.endpointDetails,
|
||||
loading: h.loading,
|
||||
error: null,
|
||||
refetch: async () => {},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@app/contexts/AppConfigContext", () => ({
|
||||
useAppConfig: () => ({
|
||||
config: null,
|
||||
loading: h.configLoading,
|
||||
error: null,
|
||||
refetch: async () => {},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@app/hooks/useGroupSigningEnabled", () => ({
|
||||
useGroupSigningEnabled: () => h.groupSigningEnabled,
|
||||
}));
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (_key: string, fallback?: string) => fallback ?? _key,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("useQuickNavToolReasons", () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
h.endpointStatus = {};
|
||||
h.endpointDetails = {};
|
||||
h.loading = false;
|
||||
h.configLoading = false;
|
||||
h.groupSigningEnabled = true;
|
||||
});
|
||||
|
||||
it("admits it does not know rather than reporting nothing wrong", () => {
|
||||
h.loading = true;
|
||||
h.endpointStatus = { automate: false };
|
||||
|
||||
expect(
|
||||
renderHook(() => useQuickNavToolReasons()).result.current,
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("reports what it last knew while the answer is being fetched again", () => {
|
||||
// Each app has its own query cache, and a reload has none at all.
|
||||
h.endpointStatus = { automate: false };
|
||||
h.endpointDetails = { automate: { reason: "CONFIG" } };
|
||||
renderHook(() => useQuickNavToolReasons());
|
||||
|
||||
h.loading = true;
|
||||
h.endpointStatus = {};
|
||||
h.endpointDetails = {};
|
||||
|
||||
const { result } = renderHook(() => useQuickNavToolReasons());
|
||||
expect(result.current?.automate).toBe("Disabled by server administrator");
|
||||
});
|
||||
|
||||
it("forgets a reason once the server stops reporting it", () => {
|
||||
h.endpointStatus = { automate: false };
|
||||
h.endpointDetails = { automate: { reason: "CONFIG" } };
|
||||
renderHook(() => useQuickNavToolReasons());
|
||||
|
||||
h.endpointStatus = { automate: true };
|
||||
h.endpointDetails = {};
|
||||
expect(renderHook(() => useQuickNavToolReasons()).result.current).toEqual(
|
||||
{},
|
||||
);
|
||||
|
||||
// The cleared state, not the old reason, is what a reload reads back.
|
||||
h.loading = true;
|
||||
expect(renderHook(() => useQuickNavToolReasons()).result.current).toEqual(
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
it("says nothing about an endpoint the server reports as available", () => {
|
||||
h.endpointStatus = { automate: true };
|
||||
|
||||
expect(renderHook(() => useQuickNavToolReasons()).result.current).toEqual(
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
it("blames the administrator when the endpoint was turned off by config", () => {
|
||||
h.endpointStatus = { automate: false };
|
||||
h.endpointDetails = { automate: { reason: "CONFIG" } };
|
||||
|
||||
const { result } = renderHook(() => useQuickNavToolReasons());
|
||||
// The tool picker's label with its trailing colon stripped.
|
||||
expect(result.current?.automate).toBe("Disabled by server administrator");
|
||||
});
|
||||
|
||||
it("blames the missing dependency when that is what the server said", () => {
|
||||
h.endpointStatus = { automate: false };
|
||||
h.endpointDetails = { automate: { reason: "DEPENDENCY" } };
|
||||
|
||||
const { result } = renderHook(() => useQuickNavToolReasons());
|
||||
expect(result.current?.automate).toBe(
|
||||
"Unavailable - required tool missing on server",
|
||||
);
|
||||
});
|
||||
|
||||
it("greys out shared signing when the server has the feature switched off", () => {
|
||||
// A whole feature rather than a removable endpoint, so it has its own signal.
|
||||
h.groupSigningEnabled = false;
|
||||
|
||||
const { result } = renderHook(() => useQuickNavToolReasons());
|
||||
expect(result.current?.sharedSign).toBe(
|
||||
"Collaborative signing isn't enabled on this server",
|
||||
);
|
||||
});
|
||||
|
||||
it("waits for the config before judging shared signing", () => {
|
||||
// The config loads separately and reads as "off" before it arrives.
|
||||
h.configLoading = true;
|
||||
h.groupSigningEnabled = false;
|
||||
|
||||
expect(
|
||||
renderHook(() => useQuickNavToolReasons()).result.current,
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMultipleEndpointsEnabled } from "@app/hooks/useEndpointConfig";
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
import { useGroupSigningEnabled } from "@app/hooks/useGroupSigningEnabled";
|
||||
import { getDisabledLabel } from "@app/components/tools/fullscreen/shared";
|
||||
import type { QuickNavToolReasons } from "@app/contexts/QuickNavHostContext";
|
||||
import type { ToolId } from "@app/types/toolId";
|
||||
|
||||
const ENTRY_ENDPOINTS = {
|
||||
automate: ["automate"],
|
||||
} satisfies Partial<Record<ToolId, string[]>>;
|
||||
|
||||
// Object.keys widens to string, which a tool-id-keyed record can't be indexed by.
|
||||
const ENDPOINT_ENTRIES = Object.keys(
|
||||
ENTRY_ENDPOINTS,
|
||||
) as (keyof typeof ENTRY_ENDPOINTS)[];
|
||||
|
||||
/** Shared signing is a feature toggle rather than an endpoint, so it has its own cause. */
|
||||
type EndpointCause = "missingDependency" | "disabledByAdmin";
|
||||
type Cause = EndpointCause | "groupSigningOff";
|
||||
type Causes = Partial<Record<ToolId, Cause>>;
|
||||
const CAUSES: Cause[] = [
|
||||
"missingDependency",
|
||||
"disabledByAdmin",
|
||||
"groupSigningOff",
|
||||
];
|
||||
|
||||
/** Causes, not sentences, so a language change can't resurrect stale text. */
|
||||
const STORAGE_KEY = "stirling.quickNav.toolCauses";
|
||||
|
||||
function readRemembered(): Causes | null {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return null;
|
||||
}
|
||||
const known = Object.entries(parsed as Record<string, unknown>).filter(
|
||||
([, cause]) => CAUSES.includes(cause as Cause),
|
||||
) as [ToolId, Cause][];
|
||||
return Object.fromEntries(known);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function remember(causes: Causes): void {
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(causes));
|
||||
} catch {
|
||||
// Won't survive the next reload.
|
||||
}
|
||||
}
|
||||
|
||||
function causesFor(
|
||||
endpointStatus: Record<string, boolean>,
|
||||
endpointDetails: Record<string, { reason?: string | null }>,
|
||||
): Causes {
|
||||
const causes: Causes = {};
|
||||
for (const entry of ENDPOINT_ENTRIES) {
|
||||
const off = ENTRY_ENDPOINTS[entry].filter(
|
||||
(name) => endpointStatus[name] === false,
|
||||
);
|
||||
if (off.length === 0) continue;
|
||||
causes[entry] = off.some(
|
||||
(name) => endpointDetails[name]?.reason === "DEPENDENCY",
|
||||
)
|
||||
? "missingDependency"
|
||||
: "disabledByAdmin";
|
||||
}
|
||||
return causes;
|
||||
}
|
||||
|
||||
/** Why a rail entry can't be used. Null means no answer yet, an empty map nothing wrong. */
|
||||
export function useQuickNavToolReasons(): QuickNavToolReasons | null {
|
||||
const { t } = useTranslation();
|
||||
const endpoints = useMemo(() => Object.values(ENTRY_ENDPOINTS).flat(), []);
|
||||
const { endpointStatus, endpointDetails, loading } =
|
||||
useMultipleEndpointsEnabled(endpoints);
|
||||
|
||||
// Read once: later reads would fight the live answer.
|
||||
const [remembered] = useState(readRemembered);
|
||||
|
||||
const { loading: configLoading } = useAppConfig();
|
||||
const groupSigningEnabled = useGroupSigningEnabled();
|
||||
|
||||
const live = useMemo(() => {
|
||||
// A half answer would dim entries it can't see yet.
|
||||
if (loading || configLoading) return null;
|
||||
const causes = causesFor(endpointStatus, endpointDetails);
|
||||
if (!groupSigningEnabled) causes.sharedSign = "groupSigningOff";
|
||||
return causes;
|
||||
}, [
|
||||
loading,
|
||||
configLoading,
|
||||
endpointStatus,
|
||||
endpointDetails,
|
||||
groupSigningEnabled,
|
||||
]);
|
||||
|
||||
// Keyed on contents: the object is rebuilt every render.
|
||||
const liveKey = live ? JSON.stringify(live) : null;
|
||||
useEffect(() => {
|
||||
if (liveKey) remember(JSON.parse(liveKey) as Causes);
|
||||
}, [liveKey]);
|
||||
|
||||
const causes = live ?? remembered;
|
||||
|
||||
return useMemo(() => {
|
||||
if (!causes) return null;
|
||||
const reasons: QuickNavToolReasons = {};
|
||||
for (const entry of Object.keys(causes) as ToolId[]) {
|
||||
const cause = causes[entry];
|
||||
if (cause === "groupSigningOff") {
|
||||
// The tool's own wording, minus the full stop.
|
||||
reasons[entry] = t(
|
||||
"sharedSign.disabledBody",
|
||||
"Collaborative signing isn't enabled on this server.",
|
||||
).replace(/\.\s*$/, "");
|
||||
continue;
|
||||
}
|
||||
if (!cause) continue;
|
||||
// These labels normally sit in front of a tool name, hence the trailing colon.
|
||||
const { key, fallback } = getDisabledLabel(cause);
|
||||
reasons[entry] = t(key, fallback).replace(/:\s*$/, "");
|
||||
}
|
||||
return reasons;
|
||||
}, [causes, t]);
|
||||
}
|
||||
@@ -115,7 +115,7 @@ export default function RightSidebar() {
|
||||
|
||||
const computedWidth = () => {
|
||||
if (isMobile) return "100%";
|
||||
if (!isPanelVisible) return "3.5rem";
|
||||
if (!isPanelVisible) return "var(--nav-rail-w)";
|
||||
return expandedWidth;
|
||||
};
|
||||
|
||||
@@ -181,7 +181,8 @@ export default function RightSidebar() {
|
||||
content={tool.name}
|
||||
position="left"
|
||||
arrow
|
||||
delay={300}
|
||||
// No delay: collapsed to icons, the tooltip is the only label.
|
||||
delay={0}
|
||||
>
|
||||
<ActionIcon
|
||||
aria-label={tool.name}
|
||||
@@ -206,7 +207,7 @@ export default function RightSidebar() {
|
||||
/* Fixed width matches the expanded panel width so the inner content is
|
||||
laid out at its final size from the moment it mounts. The outer
|
||||
.tool-panel clips it (overflow-hidden) while it animates from the
|
||||
collapsed 3.5rem width — text/icons stay put and just come into view
|
||||
collapsed rail width — text/icons stay put and just come into view
|
||||
instead of jiggling as space becomes available. */
|
||||
style={{
|
||||
opacity: 1,
|
||||
|
||||
@@ -19,12 +19,12 @@
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Flush, with a rule on the workbench side mirroring the file sidebar's. */
|
||||
.tool-panel--floating {
|
||||
margin: var(--nav-gutter) var(--nav-gutter) var(--nav-gutter) 0;
|
||||
height: calc(100vh - (var(--nav-gutter) * 2));
|
||||
/* Tracks the frame, which is dvh; vh would cut the bottom off on mobile. */
|
||||
height: 100%;
|
||||
background: var(--c-surface);
|
||||
border: 1px solid var(--c-border-subtle);
|
||||
border-radius: var(--radius-nav);
|
||||
border-inline-start: 1px solid var(--c-border-subtle);
|
||||
}
|
||||
|
||||
.tool-panel__collapsed-strip {
|
||||
|
||||
@@ -185,23 +185,24 @@ export function useViewerWorkbenchBarButtons(
|
||||
section: "top" as const,
|
||||
order: 10,
|
||||
render: ({ disabled }) => (
|
||||
<Tooltip
|
||||
content={searchLabel}
|
||||
<Popover
|
||||
position={tooltipPosition}
|
||||
offset={12}
|
||||
arrow
|
||||
portalTarget={document.body}
|
||||
withArrow
|
||||
shadow="md"
|
||||
offset={8}
|
||||
opened={isSearchInterfaceVisible}
|
||||
onClose={viewer.searchInterfaceActions.close}
|
||||
>
|
||||
<Popover
|
||||
position={tooltipPosition}
|
||||
withArrow
|
||||
shadow="md"
|
||||
offset={8}
|
||||
opened={isSearchInterfaceVisible}
|
||||
onClose={viewer.searchInterfaceActions.close}
|
||||
>
|
||||
<Popover.Target>
|
||||
<div style={{ display: "inline-flex" }}>
|
||||
<Popover.Target>
|
||||
<div style={{ display: "inline-flex" }}>
|
||||
{/* Inside the Popover: Tooltip binds by cloning, and Popover passes no ref on. */}
|
||||
<Tooltip
|
||||
content={searchLabel}
|
||||
position={tooltipPosition}
|
||||
offset={12}
|
||||
arrow
|
||||
portalTarget={document.body}
|
||||
>
|
||||
<ActionIcon
|
||||
variant="tertiary"
|
||||
className="workbench-bar-action-icon"
|
||||
@@ -215,18 +216,18 @@ export function useViewerWorkbenchBarButtons(
|
||||
height="1.25rem"
|
||||
/>
|
||||
</ActionIcon>
|
||||
</div>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<div style={{ minWidth: "20rem" }}>
|
||||
<SearchInterface
|
||||
visible={isSearchInterfaceVisible}
|
||||
onClose={viewer.searchInterfaceActions.close}
|
||||
/>
|
||||
</div>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
</Tooltip>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<div style={{ minWidth: "20rem" }}>
|
||||
<SearchInterface
|
||||
visible={isSearchInterfaceVisible}
|
||||
onClose={viewer.searchInterfaceActions.close}
|
||||
/>
|
||||
</div>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, act } from "@testing-library/react";
|
||||
import {
|
||||
QuickNavHostProvider,
|
||||
useQuickNavHost,
|
||||
useRegisterQuickNavHost,
|
||||
useSuppressQuickNavRail,
|
||||
} from "@app/contexts/QuickNavHostContext";
|
||||
|
||||
function Probe({ onRead }: { onRead: (value: unknown) => void }) {
|
||||
const host = useQuickNavHost();
|
||||
onRead({
|
||||
appMounted: host?.appMounted,
|
||||
chromeless: host?.chromeless,
|
||||
identity: host?.identity,
|
||||
openSettings: Boolean(host?.actions.current?.openSettings),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
function App() {
|
||||
useRegisterQuickNavHost(
|
||||
{ identity: { displayName: "Ada", profilePictureUrl: null } },
|
||||
{ openSettings: () => {} },
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
function LoginRoute() {
|
||||
useSuppressQuickNavRail();
|
||||
return null;
|
||||
}
|
||||
|
||||
function setup() {
|
||||
let latest: Record<string, unknown> = {};
|
||||
const view = render(
|
||||
<QuickNavHostProvider>
|
||||
<Probe onRead={(value) => (latest = value as Record<string, unknown>)} />
|
||||
<App />
|
||||
</QuickNavHostProvider>,
|
||||
);
|
||||
return { view, read: () => latest };
|
||||
}
|
||||
|
||||
describe("QuickNavHostContext", () => {
|
||||
it("keeps what the app published after it unmounts, but drops its handlers", () => {
|
||||
// Data survives the gap between one app unmounting and the next registering.
|
||||
const { view, read } = setup();
|
||||
|
||||
expect(read().appMounted).toBe(true);
|
||||
expect(read().identity).toEqual({
|
||||
displayName: "Ada",
|
||||
profilePictureUrl: null,
|
||||
});
|
||||
expect(read().openSettings).toBe(true);
|
||||
|
||||
view.rerender(
|
||||
<QuickNavHostProvider>
|
||||
<Probe onRead={() => {}} />
|
||||
</QuickNavHostProvider>,
|
||||
);
|
||||
|
||||
// Re-read through a fresh probe in the same provider.
|
||||
let after: Record<string, unknown> = {};
|
||||
view.rerender(
|
||||
<QuickNavHostProvider>
|
||||
<Probe onRead={(value) => (after = value as Record<string, unknown>)} />
|
||||
</QuickNavHostProvider>,
|
||||
);
|
||||
expect(after.appMounted).toBe(true);
|
||||
expect(after.openSettings).toBe(false);
|
||||
});
|
||||
|
||||
it("hides the bar while a route with no app chrome is on screen", () => {
|
||||
// appMounted is sticky, so it can't answer "is an app on screen now".
|
||||
const { view, read } = setup();
|
||||
expect(read().chromeless).toBe(false);
|
||||
|
||||
act(() => {
|
||||
view.rerender(
|
||||
<QuickNavHostProvider>
|
||||
<Probe onRead={() => {}} />
|
||||
<App />
|
||||
<LoginRoute />
|
||||
</QuickNavHostProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
let during: Record<string, unknown> = {};
|
||||
view.rerender(
|
||||
<QuickNavHostProvider>
|
||||
<Probe
|
||||
onRead={(value) => (during = value as Record<string, unknown>)}
|
||||
/>
|
||||
<App />
|
||||
<LoginRoute />
|
||||
</QuickNavHostProvider>,
|
||||
);
|
||||
expect(during.chromeless).toBe(true);
|
||||
});
|
||||
|
||||
it("brings the bar back when that route leaves", () => {
|
||||
const { view } = setup();
|
||||
|
||||
act(() => {
|
||||
view.rerender(
|
||||
<QuickNavHostProvider>
|
||||
<Probe onRead={() => {}} />
|
||||
<App />
|
||||
<LoginRoute />
|
||||
</QuickNavHostProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
let after: Record<string, unknown> = {};
|
||||
act(() => {
|
||||
view.rerender(
|
||||
<QuickNavHostProvider>
|
||||
<Probe
|
||||
onRead={(value) => (after = value as Record<string, unknown>)}
|
||||
/>
|
||||
<App />
|
||||
</QuickNavHostProvider>,
|
||||
);
|
||||
});
|
||||
expect(after.chromeless).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
import type { ToolId } from "@app/types/toolId";
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
export type QuickNavToolReasons = Partial<Record<ToolId, string>>;
|
||||
|
||||
export interface QuickNavIdentity {
|
||||
displayName: string;
|
||||
profilePictureUrl: string | null;
|
||||
}
|
||||
|
||||
export interface QuickNavHostData {
|
||||
/** Sticky: one app unmounts before the next one registers. */
|
||||
appMounted: boolean;
|
||||
identity: QuickNavIdentity | null;
|
||||
signingBadge: number;
|
||||
portalAccess: boolean;
|
||||
readerMode: boolean;
|
||||
/** The app owns the panel; the rail's bell only reports its state. */
|
||||
notificationsOpen: boolean;
|
||||
/** Translated; absent means usable. */
|
||||
toolReasons: QuickNavToolReasons;
|
||||
/** Mirrors `openSettings`, which lives in a ref and so cannot trigger a render. */
|
||||
hasSettings: boolean;
|
||||
}
|
||||
|
||||
export interface QuickNavHostActions {
|
||||
openSettings?: () => void;
|
||||
/** The editor reads its tool from the URL only on mount. */
|
||||
selectTool?: (toolId: ToolId) => void;
|
||||
setReaderMode?: (on: boolean) => void;
|
||||
toggleNotifications?: () => void;
|
||||
goToDefaultState?: () => void;
|
||||
requestNavigation?: (go: () => void) => void;
|
||||
}
|
||||
|
||||
interface QuickNavHostValue extends QuickNavHostData {
|
||||
/** Reset on unmount, unlike the data above. */
|
||||
chromeless: boolean;
|
||||
setChromeless: (chromeless: boolean) => void;
|
||||
/** A ref, so a click reaches the app currently mounted. */
|
||||
actions: React.RefObject<QuickNavHostActions>;
|
||||
setData: (data: Partial<QuickNavHostData>) => void;
|
||||
setActions: (actions: QuickNavHostActions) => void;
|
||||
}
|
||||
|
||||
const EMPTY_REASONS: QuickNavToolReasons = {};
|
||||
|
||||
const EMPTY_DATA: QuickNavHostData = {
|
||||
appMounted: false,
|
||||
toolReasons: EMPTY_REASONS,
|
||||
identity: null,
|
||||
signingBadge: 0,
|
||||
portalAccess: false,
|
||||
readerMode: false,
|
||||
notificationsOpen: false,
|
||||
hasSettings: false,
|
||||
};
|
||||
|
||||
function sameReasons(
|
||||
next: QuickNavToolReasons,
|
||||
prev: QuickNavToolReasons,
|
||||
): boolean {
|
||||
const nextKeys = Object.keys(next);
|
||||
if (nextKeys.length !== Object.keys(prev).length) return false;
|
||||
return nextKeys.every((key) => next[key as ToolId] === prev[key as ToolId]);
|
||||
}
|
||||
|
||||
const QuickNavHostContext = createContext<QuickNavHostValue | null>(null);
|
||||
|
||||
/** Outside both apps' providers, so each app registers what only it knows. */
|
||||
export function QuickNavHostProvider({ children }: { children: ReactNode }) {
|
||||
const [data, setDataState] = useState<QuickNavHostData>(EMPTY_DATA);
|
||||
const [chromeless, setChromelessState] = useState(false);
|
||||
const actions = useRef<QuickNavHostActions>({});
|
||||
|
||||
const setData = useCallback((next: Partial<QuickNavHostData>) => {
|
||||
setDataState((prev) => {
|
||||
const merged = { ...prev, ...next };
|
||||
const unchanged =
|
||||
merged.appMounted === prev.appMounted &&
|
||||
merged.signingBadge === prev.signingBadge &&
|
||||
merged.portalAccess === prev.portalAccess &&
|
||||
merged.readerMode === prev.readerMode &&
|
||||
merged.notificationsOpen === prev.notificationsOpen &&
|
||||
merged.hasSettings === prev.hasSettings &&
|
||||
merged.identity?.displayName === prev.identity?.displayName &&
|
||||
merged.identity?.profilePictureUrl ===
|
||||
prev.identity?.profilePictureUrl &&
|
||||
// Compared by value: the object is rebuilt every render.
|
||||
sameReasons(merged.toolReasons, prev.toolReasons);
|
||||
return unchanged ? prev : merged;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setActions = useCallback((next: QuickNavHostActions) => {
|
||||
actions.current = next;
|
||||
}, []);
|
||||
|
||||
const setChromeless = useCallback((next: boolean) => {
|
||||
setChromelessState(next);
|
||||
}, []);
|
||||
|
||||
const value = useMemo<QuickNavHostValue>(
|
||||
() => ({
|
||||
...data,
|
||||
chromeless,
|
||||
actions,
|
||||
setData,
|
||||
setActions,
|
||||
setChromeless,
|
||||
}),
|
||||
[data, chromeless, setData, setActions, setChromeless],
|
||||
);
|
||||
|
||||
return (
|
||||
<QuickNavHostContext.Provider value={value}>
|
||||
{children}
|
||||
</QuickNavHostContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useQuickNavHost(): QuickNavHostValue | null {
|
||||
return useContext(QuickNavHostContext);
|
||||
}
|
||||
|
||||
/** No-ops outside the provider. */
|
||||
export function useRegisterQuickNavHost(
|
||||
data: Partial<QuickNavHostData>,
|
||||
actions: QuickNavHostActions,
|
||||
): void {
|
||||
const host = useQuickNavHost();
|
||||
const {
|
||||
identity,
|
||||
signingBadge,
|
||||
portalAccess,
|
||||
readerMode,
|
||||
notificationsOpen,
|
||||
toolReasons,
|
||||
} = data;
|
||||
const hasSettings = Boolean(actions.openSettings);
|
||||
|
||||
useEffect(() => {
|
||||
host?.setData({
|
||||
appMounted: true,
|
||||
identity: identity ?? null,
|
||||
signingBadge: signingBadge ?? 0,
|
||||
portalAccess: portalAccess ?? false,
|
||||
readerMode: readerMode ?? false,
|
||||
notificationsOpen: notificationsOpen ?? false,
|
||||
// Omitted when unknown, so the last answer survives a re-fetch.
|
||||
...(toolReasons ? { toolReasons } : {}),
|
||||
hasSettings,
|
||||
});
|
||||
// By field: identity is rebuilt every render.
|
||||
}, [
|
||||
host,
|
||||
identity?.displayName,
|
||||
identity?.profilePictureUrl,
|
||||
signingBadge,
|
||||
portalAccess,
|
||||
readerMode,
|
||||
notificationsOpen,
|
||||
toolReasons,
|
||||
hasSettings,
|
||||
]);
|
||||
|
||||
const setActions = host?.setActions;
|
||||
|
||||
// No deps: a click has to reach the current closure.
|
||||
useEffect(() => {
|
||||
setActions?.(actions);
|
||||
});
|
||||
|
||||
// Handlers only: clearing the data too would blink the controls mid-switch.
|
||||
useEffect(
|
||||
() => () => {
|
||||
setActions?.({});
|
||||
},
|
||||
[setActions],
|
||||
);
|
||||
}
|
||||
|
||||
/** `appMounted` is sticky, so a screen that isn't the app has to say so itself. */
|
||||
export function useSuppressQuickNavRail(active = true): void {
|
||||
const host = useQuickNavHost();
|
||||
const setChromeless = host?.setChromeless;
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
setChromeless?.(true);
|
||||
return () => setChromeless?.(false);
|
||||
}, [active, setChromeless]);
|
||||
}
|
||||
@@ -62,6 +62,11 @@ export function SidebarProvider({ children }: SidebarProviderProps) {
|
||||
);
|
||||
}
|
||||
|
||||
/** For components that render outside a SidebarProvider, such as the rail's tooltips. */
|
||||
export function useOptionalSidebarContext(): SidebarContextValue | undefined {
|
||||
return useContext(SidebarContext);
|
||||
}
|
||||
|
||||
export function useSidebarContext(): SidebarContextValue {
|
||||
const context = useContext(SidebarContext);
|
||||
if (context === undefined) {
|
||||
|
||||
@@ -29,6 +29,8 @@ import {
|
||||
isBaseWorkbench,
|
||||
} from "@app/types/workbench";
|
||||
import { useNavigationUrlSync } from "@app/hooks/useUrlSync";
|
||||
import { stripBasePath } from "@app/constants/app";
|
||||
import { EDITOR_BASENAME } from "@app/routes/editorBasename";
|
||||
import { filterToolRegistryByQuery } from "@app/utils/toolSearch";
|
||||
import { useToolHistory } from "@app/hooks/tools/useUserToolActivity";
|
||||
import {
|
||||
@@ -218,8 +220,8 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
|
||||
const setReaderMode = useCallback(
|
||||
(mode: boolean) => {
|
||||
if (mode) {
|
||||
// Reading is a mode the open document is put into, not a tool run on it.
|
||||
actions.setWorkbench("viewer");
|
||||
actions.setSelectedTool("read");
|
||||
}
|
||||
dispatch({ type: "SET_READER_MODE", payload: mode });
|
||||
},
|
||||
@@ -373,15 +375,28 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
|
||||
// This runs once to navigate to the user's preferred tab (read/automate)
|
||||
// instead of always starting on the tools tab.
|
||||
const hasAppliedStartupView = React.useRef(false);
|
||||
// Set when the startup view picks the tool, so the URL sync knows this
|
||||
// selection came from a preference and must not be written to the address.
|
||||
const startupSelectedToolRef = React.useRef<ToolId | null>(null);
|
||||
useEffect(() => {
|
||||
if (hasAppliedStartupView.current) return;
|
||||
// The URL wins: the startup view decides what you see when you arrive at the
|
||||
// editor's home, never what a deep link to a tool shows. Without this, a
|
||||
// "Reader" preference rewrote every /<tool> link to /read.
|
||||
const path = stripBasePath(window.location.pathname);
|
||||
if (path !== "/" && path !== EDITOR_BASENAME) {
|
||||
hasAppliedStartupView.current = true;
|
||||
return;
|
||||
}
|
||||
const startupView = preferences.defaultStartupView;
|
||||
if (startupView === "read") {
|
||||
hasAppliedStartupView.current = true;
|
||||
startupSelectedToolRef.current = "read";
|
||||
setReaderMode(true);
|
||||
actions.setSelectedTool("read");
|
||||
} else if (startupView === "automate") {
|
||||
hasAppliedStartupView.current = true;
|
||||
startupSelectedToolRef.current = "automate";
|
||||
actions.setSelectedTool("automate");
|
||||
setLeftPanelView("toolContent");
|
||||
}
|
||||
@@ -573,6 +588,7 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
|
||||
handleBackToTools,
|
||||
allTools,
|
||||
true,
|
||||
startupSelectedToolRef,
|
||||
);
|
||||
|
||||
// Ref-backed wrappers so callback identities stay stable across renders.
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { useRef } from "react";
|
||||
import type { ToolId } from "@app/types/toolId";
|
||||
|
||||
const h = vi.hoisted(() => ({
|
||||
updateToolRoute: vi.fn(),
|
||||
clearToolRoute: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@app/utils/urlRouting", () => ({
|
||||
parseToolRoute: () => ({ workbench: "fileEditor", toolId: null }),
|
||||
updateToolRoute: h.updateToolRoute,
|
||||
clearToolRoute: h.clearToolRoute,
|
||||
}));
|
||||
vi.mock("@app/utils/scarfTracking", () => ({ firePixel: vi.fn() }));
|
||||
vi.mock("@app/contexts/AppConfigContext", () => ({
|
||||
useAppConfig: () => ({ config: { premiumEnabled: true } }),
|
||||
}));
|
||||
|
||||
import { useNavigationUrlSync } from "@app/hooks/useUrlSync";
|
||||
|
||||
const registry = {
|
||||
read: { name: "Read", workbench: "viewer" },
|
||||
compress: { name: "Compress", workbench: "fileEditor" },
|
||||
} as never;
|
||||
|
||||
/** Drives the hook the way ToolWorkflowContext does, with a startup marker. */
|
||||
function useHarness(selectedTool: ToolId | null, startupTool: ToolId | null) {
|
||||
const ref = useRef<ToolId | null>(startupTool);
|
||||
useNavigationUrlSync(selectedTool, vi.fn(), vi.fn(), registry, true, ref);
|
||||
return ref;
|
||||
}
|
||||
|
||||
describe("useNavigationUrlSync — startup-view selections", () => {
|
||||
beforeEach(() => h.updateToolRoute.mockClear());
|
||||
|
||||
// The default-startup-view preference selects a tool to change the *view*.
|
||||
// Writing it to the address turned every visit to /editor into /read.
|
||||
it("never writes the URL for the startup-applied tool", () => {
|
||||
const { rerender } = renderHook(
|
||||
({ tool }: { tool: ToolId | null }) => useHarness(tool, "read" as ToolId),
|
||||
{ initialProps: { tool: null as ToolId | null } },
|
||||
);
|
||||
rerender({ tool: "read" as ToolId });
|
||||
expect(h.updateToolRoute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// The effect re-runs whenever the registry identity changes, so a marker that
|
||||
// was consumed on first sight let the second run write /read anyway.
|
||||
it("survives a re-run for the same tool", () => {
|
||||
const { rerender } = renderHook(
|
||||
({ tool }: { tool: ToolId | null }) => useHarness(tool, "read" as ToolId),
|
||||
{ initialProps: { tool: null as ToolId | null } },
|
||||
);
|
||||
rerender({ tool: "read" as ToolId });
|
||||
rerender({ tool: "read" as ToolId });
|
||||
rerender({ tool: "read" as ToolId });
|
||||
expect(h.updateToolRoute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still writes the URL when the user picks a different tool", () => {
|
||||
const { rerender } = renderHook(
|
||||
({ tool }: { tool: ToolId | null }) => useHarness(tool, "read" as ToolId),
|
||||
{ initialProps: { tool: null as ToolId | null } },
|
||||
);
|
||||
rerender({ tool: "read" as ToolId });
|
||||
rerender({ tool: "compress" as ToolId });
|
||||
expect(h.updateToolRoute).toHaveBeenCalledWith("compress", registry, false);
|
||||
});
|
||||
|
||||
it("writes the URL for a tool chosen without a startup marker", () => {
|
||||
const { rerender } = renderHook(
|
||||
({ tool }: { tool: ToolId | null }) => useHarness(tool, null),
|
||||
{ initialProps: { tool: null as ToolId | null } },
|
||||
);
|
||||
rerender({ tool: "read" as ToolId });
|
||||
expect(h.updateToolRoute).toHaveBeenCalledWith("read", registry, false);
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@
|
||||
* URL synchronization hooks for tool routing with registry support
|
||||
*/
|
||||
|
||||
import { useEffect, useCallback, useRef } from "react";
|
||||
import { useEffect, useCallback, useRef, type MutableRefObject } from "react";
|
||||
import { ToolId } from "@app/types/toolId";
|
||||
import {
|
||||
parseToolRoute,
|
||||
@@ -24,6 +24,11 @@ export function useNavigationUrlSync(
|
||||
clearToolSelection: () => void,
|
||||
registry: ToolRegistry,
|
||||
enableSync: boolean = true,
|
||||
/**
|
||||
* Tool the default-startup-view preference selected, if any. That selection
|
||||
* sets the view, not the address, so it must not be written to the URL.
|
||||
*/
|
||||
startupSelectedToolRef?: MutableRefObject<ToolId | null>,
|
||||
) {
|
||||
const { config } = useAppConfig();
|
||||
const premiumEnabled = config?.premiumEnabled;
|
||||
@@ -77,8 +82,16 @@ export function useNavigationUrlSync(
|
||||
useEffect(() => {
|
||||
if (!enableSync) return;
|
||||
|
||||
const startupTool = startupSelectedToolRef?.current ?? null;
|
||||
|
||||
if (selectedTool) {
|
||||
updateToolRoute(selectedTool, registry, false); // Use pushState for user navigation
|
||||
// A startup-view selection is a view preference, not a navigation: writing
|
||||
// it here rewrote /editor to /read on every load. The effect re-runs
|
||||
// whenever the registry identity changes, so the marker has to survive
|
||||
// until the selection actually moves off it (cleared below).
|
||||
if (startupTool !== selectedTool) {
|
||||
updateToolRoute(selectedTool, registry, false); // Use pushState for user navigation
|
||||
}
|
||||
} else if (prevSelectedTool.current !== null) {
|
||||
// Only clear URL if we had a tool before (user navigated away)
|
||||
// Don't clear on initial load when both current and previous are null
|
||||
@@ -88,8 +101,19 @@ export function useNavigationUrlSync(
|
||||
}
|
||||
}
|
||||
|
||||
// Spent once the user leaves the startup-applied tool, so re-picking it
|
||||
// later is a real navigation and does update the URL.
|
||||
if (
|
||||
startupSelectedToolRef &&
|
||||
startupTool !== null &&
|
||||
prevSelectedTool.current === startupTool &&
|
||||
selectedTool !== startupTool
|
||||
) {
|
||||
startupSelectedToolRef.current = null;
|
||||
}
|
||||
|
||||
prevSelectedTool.current = selectedTool;
|
||||
}, [selectedTool, registry, enableSync]);
|
||||
}, [selectedTool, registry, enableSync, startupSelectedToolRef]);
|
||||
|
||||
// Handle browser back/forward navigation
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { forwardRef, useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||
import { Group } from "@mantine/core";
|
||||
@@ -14,6 +21,7 @@ import { useFileContext } from "@app/contexts/file/fileHooks";
|
||||
import {
|
||||
useNavigationState,
|
||||
useNavigationActions,
|
||||
useNavigationGuard,
|
||||
} from "@app/contexts/NavigationContext";
|
||||
import { isApplyingRestoredView } from "@app/services/workbenchSession";
|
||||
import { useViewer } from "@app/contexts/ViewerContext";
|
||||
@@ -28,10 +36,21 @@ import FileSidebar from "@app/components/shared/FileSidebar";
|
||||
import FileManager from "@app/components/FileManager";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import AppConfigModal from "@app/components/shared/AppConfigModalLazy";
|
||||
import { getStartupNavigationAction } from "@app/utils/homePageNavigation";
|
||||
import {
|
||||
getStartupNavigationAction,
|
||||
getDefaultWorkbenchForFileCount,
|
||||
} from "@app/utils/homePageNavigation";
|
||||
import { EDITOR_BASENAME } from "@app/routes/editorBasename";
|
||||
import { stripBasePath } from "@app/constants/app";
|
||||
import { HomePageExtensions } from "@app/components/home/HomePageExtensions";
|
||||
import { QuickNavHostBridge } from "@app/components/shared/quickNav/QuickNavHostBridge";
|
||||
import type { QuickNavToolReasons } from "@app/contexts/QuickNavHostContext";
|
||||
import {
|
||||
getToolDisabledReason,
|
||||
getDisabledLabel,
|
||||
} from "@app/components/tools/fullscreen/shared";
|
||||
import { useOtherAppSwitch } from "@app/hooks/useOtherAppSwitch";
|
||||
import { consumeReaderModeRequest } from "@app/utils/pendingReaderMode";
|
||||
import {
|
||||
FilesPageProvider,
|
||||
useFilesPage,
|
||||
@@ -42,6 +61,7 @@ import { FolderTreePanel } from "@app/components/filesPage/FolderTreePanel";
|
||||
import type { FileSidebarProps } from "@app/components/shared/FileSidebar";
|
||||
|
||||
import { Button } from "@app/ui/Button";
|
||||
import "@app/components/layout/WorkspaceFrame.css";
|
||||
import "@app/pages/HomePage.css";
|
||||
|
||||
const SIDEBAR_COLLAPSED_STORAGE_KEY = "stirling.fileSidebarCollapsed";
|
||||
@@ -90,9 +110,11 @@ export default function HomePage() {
|
||||
handleToolSelect,
|
||||
handleBackToTools,
|
||||
readerMode,
|
||||
setReaderMode,
|
||||
setLeftPanelView,
|
||||
toolAvailability,
|
||||
customWorkbenchViews,
|
||||
toolRegistry,
|
||||
} = useToolWorkflow();
|
||||
|
||||
const navigate = useNavigate();
|
||||
@@ -103,6 +125,7 @@ export default function HomePage() {
|
||||
const [activeMobileView, setActiveMobileView] = useState<MobileView>("tools");
|
||||
const isProgrammaticScroll = useRef(false);
|
||||
const [configModalOpen, setConfigModalOpen] = useState(false);
|
||||
const otherApp = useOtherAppSwitch();
|
||||
const location = useLocation();
|
||||
// Persisted user preference for the FileSidebar collapsed state. Auto-
|
||||
// collapse on /files is layered on top in the transition effect below and
|
||||
@@ -152,8 +175,64 @@ export default function HomePage() {
|
||||
|
||||
const { activeFiles } = useFileContext();
|
||||
const navigationState = useNavigationState();
|
||||
const { requestNavigation } = useNavigationGuard();
|
||||
|
||||
// From the processor's Reader entry. Ref-guarded: one-shot, and StrictMode double-invokes.
|
||||
const consumedReaderRequest = useRef(false);
|
||||
useEffect(() => {
|
||||
if (consumedReaderRequest.current) return;
|
||||
consumedReaderRequest.current = true;
|
||||
if (consumeReaderModeRequest()) setReaderMode(true);
|
||||
}, [setReaderMode]);
|
||||
const { actions } = useNavigationActions();
|
||||
|
||||
const { searchInterfaceActions } = useViewer();
|
||||
|
||||
// Reading hides both search controls, so leave it first. e.code, for non-QWERTY layouts.
|
||||
const focusSearchAfterRestore = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!readerMode) return;
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
const combo = (e.metaKey || e.ctrlKey) && !e.altKey && !e.shiftKey;
|
||||
if (!combo) return;
|
||||
if (e.code !== "KeyK" && e.code !== "KeyF") return;
|
||||
// Same carve-out the search itself makes: a dialog owns the keyboard.
|
||||
if ((e.target as HTMLElement | null)?.closest?.('[role="dialog"]'))
|
||||
return;
|
||||
e.preventDefault();
|
||||
setReaderMode(false);
|
||||
if (e.code === "KeyK") {
|
||||
focusSearchAfterRestore.current = true;
|
||||
return;
|
||||
}
|
||||
// Visibility is state, so it can open before the bar it renders in exists.
|
||||
searchInterfaceActions.open();
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [readerMode, setReaderMode, searchInterfaceActions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (readerMode || !focusSearchAfterRestore.current) return;
|
||||
focusSearchAfterRestore.current = false;
|
||||
requestAnimationFrame(() =>
|
||||
window.dispatchEvent(new Event("superSearch:focus")),
|
||||
);
|
||||
}, [readerMode]);
|
||||
|
||||
// Clean slate: no tool, out of the file library and reading.
|
||||
const goToDefaultState = useCallback(() => {
|
||||
handleBackToTools();
|
||||
if (location.pathname.startsWith("/files")) navigate(EDITOR_BASENAME);
|
||||
actions.setWorkbench(getDefaultWorkbenchForFileCount(activeFiles.length));
|
||||
}, [
|
||||
handleBackToTools,
|
||||
location.pathname,
|
||||
navigate,
|
||||
actions,
|
||||
activeFiles.length,
|
||||
]);
|
||||
|
||||
// Sync the /files* URL into the workbench state so the file manager view
|
||||
// takes over the workbench area when the user lands on it. This is the
|
||||
// only state-of-truth for the active workbench, so keep the URL pinned.
|
||||
@@ -194,6 +273,17 @@ export default function HomePage() {
|
||||
prevWorkbenchRef.current = curr;
|
||||
// fileSidebarCollapsed read as snapshot on transition only.
|
||||
}, [navigationState.workbench]);
|
||||
// Imperative, so the toggle still works while reading. Never persisted: not a preference.
|
||||
const prevReaderModeRef = useRef(readerMode);
|
||||
useEffect(() => {
|
||||
if (readerMode !== prevReaderModeRef.current) {
|
||||
setFileSidebarCollapsed(
|
||||
readerMode ? true : readPersistedSidebarCollapsed(),
|
||||
);
|
||||
prevReaderModeRef.current = readerMode;
|
||||
}
|
||||
}, [readerMode]);
|
||||
|
||||
const { setActiveFileIndex } = useViewer();
|
||||
const prevFileCountRef = useRef(activeFiles.length);
|
||||
|
||||
@@ -242,6 +332,38 @@ export default function HomePage() {
|
||||
|
||||
const brandAltText = t("home.mobile.brandAlt", "Stirling PDF logo");
|
||||
|
||||
// The tool picker's own helpers, so the wording can't drift.
|
||||
const quickNavToolReasons = useMemo(() => {
|
||||
const reasons: QuickNavToolReasons = {};
|
||||
for (const id of ["automate", "sharedSign"] as const) {
|
||||
const tool = toolRegistry[id];
|
||||
if (!tool) continue;
|
||||
const disabledReason = getToolDisabledReason(
|
||||
id,
|
||||
tool,
|
||||
toolAvailability,
|
||||
config?.premiumEnabled,
|
||||
);
|
||||
if (!disabledReason) continue;
|
||||
const { key, fallback } = getDisabledLabel(disabledReason);
|
||||
reasons[id] = t(key, fallback).replace(/:\s*$/, "");
|
||||
}
|
||||
return reasons;
|
||||
}, [toolRegistry, toolAvailability, config?.premiumEnabled, t]);
|
||||
|
||||
// Shared with the sidebar's own toggle. On /files it leaves rather than collapses.
|
||||
const handleSidebarToggle = useCallback(() => {
|
||||
if (navigationState.workbench === "myFiles") {
|
||||
navigate(EDITOR_BASENAME);
|
||||
return;
|
||||
}
|
||||
setFileSidebarCollapsed((c) => {
|
||||
const next = !c;
|
||||
writePersistedSidebarCollapsed(next);
|
||||
return next;
|
||||
});
|
||||
}, [navigationState.workbench, navigate]);
|
||||
|
||||
const [showSwipeHint, setShowSwipeHint] = useState(
|
||||
() => !readSwipeHintSeen(),
|
||||
);
|
||||
@@ -395,6 +517,16 @@ export default function HomePage() {
|
||||
return (
|
||||
<div className="h-screen overflow-hidden">
|
||||
<HomePageExtensions />
|
||||
<QuickNavHostBridge
|
||||
portalAccess={Boolean(otherApp)}
|
||||
onOpenSettings={() => setConfigModalOpen(true)}
|
||||
requestNavigation={requestNavigation}
|
||||
readerMode={readerMode}
|
||||
onSetReaderMode={setReaderMode}
|
||||
onGoToDefaultState={goToDefaultState}
|
||||
onSelectTool={handleToolSelect}
|
||||
toolReasons={quickNavToolReasons}
|
||||
/>
|
||||
<FilesPageProvider>
|
||||
{isMobile ? (
|
||||
<div
|
||||
@@ -577,39 +709,30 @@ export default function HomePage() {
|
||||
className="flex-nowrap flex"
|
||||
bg="var(--c-bg)"
|
||||
>
|
||||
<MyFilesAwareFileSidebar
|
||||
ref={quickAccessRef}
|
||||
active={navigationState.workbench === "myFiles"}
|
||||
// /files always shows the rail collapsed - force it here so a
|
||||
// deep-link/reload onto /files (no workbench transition) still
|
||||
// collapses, and a manual expand can't stick.
|
||||
collapsed={
|
||||
navigationState.workbench === "myFiles" || fileSidebarCollapsed
|
||||
}
|
||||
toggleAriaLabel={
|
||||
navigationState.workbench === "myFiles"
|
||||
? t("fileSidebar.leaveMyFiles", "Leave My Files")
|
||||
: undefined
|
||||
}
|
||||
// Back-arrow on /files; burger elsewhere.
|
||||
toggleIcon={
|
||||
navigationState.workbench === "myFiles" ? (
|
||||
<ArrowBackIcon />
|
||||
) : undefined
|
||||
}
|
||||
onToggleCollapse={() => {
|
||||
if (navigationState.workbench === "myFiles") {
|
||||
navigate(EDITOR_BASENAME);
|
||||
return;
|
||||
<div className="workspace-frame">
|
||||
<MyFilesAwareFileSidebar
|
||||
ref={quickAccessRef}
|
||||
accountHoisted
|
||||
toggleAriaLabel={
|
||||
navigationState.workbench === "myFiles"
|
||||
? t("fileSidebar.leaveMyFiles", "Leave File library")
|
||||
: undefined
|
||||
}
|
||||
setFileSidebarCollapsed((c) => {
|
||||
const next = !c;
|
||||
writePersistedSidebarCollapsed(next);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
onOpenSettings={() => setConfigModalOpen(true)}
|
||||
/>
|
||||
toggleIcon={
|
||||
navigationState.workbench === "myFiles" ? (
|
||||
<ArrowBackIcon />
|
||||
) : undefined
|
||||
}
|
||||
active={navigationState.workbench === "myFiles"}
|
||||
// Forced: a deep link to /files has no transition to collapse on.
|
||||
collapsed={
|
||||
navigationState.workbench === "myFiles" ||
|
||||
fileSidebarCollapsed
|
||||
}
|
||||
onToggleCollapse={handleSidebarToggle}
|
||||
onOpenSettings={() => setConfigModalOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
<FolderTreePanel active={navigationState.workbench === "myFiles"} />
|
||||
<Workbench />
|
||||
{!hideToolPanel && <RightSidebar />}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
/** Whether this build ships the processor. Shadowed per build. */
|
||||
export const HAS_PORTAL = false;
|
||||
@@ -0,0 +1,76 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@app/components/toast", () => ({ alert: vi.fn() }));
|
||||
vi.mock("@app/services/specialErrorToasts", () => ({
|
||||
showSpecialErrorToast: vi.fn(() => false),
|
||||
}));
|
||||
vi.mock("@app/services/saasErrorInterceptor", () => ({
|
||||
handleSaaSError: vi.fn(() => false),
|
||||
}));
|
||||
vi.mock("@app/services/errorUtils", () => ({
|
||||
broadcastErroredFiles: vi.fn(),
|
||||
extractErrorFileIds: vi.fn(() => []),
|
||||
normalizeAxiosErrorData: vi.fn(async (d: unknown) => d),
|
||||
}));
|
||||
|
||||
const hrefs: string[] = [];
|
||||
|
||||
/** Serve the app from `base`, sitting on `pathname`, then load the handler fresh. */
|
||||
async function loadAt(base: string, pathname: string) {
|
||||
document.head.innerHTML = `<base href="${base}" />`;
|
||||
Object.defineProperty(window, "location", {
|
||||
configurable: true,
|
||||
value: {
|
||||
pathname,
|
||||
search: "",
|
||||
origin: "http://localhost:3000",
|
||||
get href() {
|
||||
// Absolute: jsdom resolves <base href> against this.
|
||||
return "http://localhost:3000" + pathname;
|
||||
},
|
||||
set href(v: string) {
|
||||
hrefs.push(v);
|
||||
},
|
||||
},
|
||||
});
|
||||
vi.resetModules();
|
||||
return (await import("@app/services/httpErrorHandler")).handleHttpError;
|
||||
}
|
||||
|
||||
const unauthorized = {
|
||||
isAxiosError: true,
|
||||
message: "unauthorized",
|
||||
config: {},
|
||||
response: { status: 401, data: {} },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
hrefs.length = 0;
|
||||
sessionStorage.clear();
|
||||
localStorage.clear();
|
||||
});
|
||||
afterEach(() => vi.resetModules());
|
||||
|
||||
describe("401 return path is router-relative", () => {
|
||||
// Login replays this through navigate(), which re-applies the router
|
||||
// basename. Carrying /app here produced /app/app/compress.
|
||||
it("strips the base path on a subpath deploy", async () => {
|
||||
const handle = await loadAt("/app/", "/app/compress");
|
||||
await handle(unauthorized);
|
||||
|
||||
expect(sessionStorage.getItem("stirling_post_login_path")).toBe(
|
||||
"/compress",
|
||||
);
|
||||
expect(hrefs[0]).toBe("/app/login?from=%2Fcompress");
|
||||
});
|
||||
|
||||
it("is unchanged at the origin root", async () => {
|
||||
const handle = await loadAt("/", "/compress");
|
||||
await handle(unauthorized);
|
||||
|
||||
expect(sessionStorage.getItem("stirling_post_login_path")).toBe(
|
||||
"/compress",
|
||||
);
|
||||
expect(hrefs[0]).toBe("/login?from=%2Fcompress");
|
||||
});
|
||||
});
|
||||
@@ -12,7 +12,8 @@ import {
|
||||
clampText,
|
||||
extractAxiosErrorMessage,
|
||||
} from "@app/services/httpErrorUtils";
|
||||
import { withBasePath } from "@app/constants/app";
|
||||
import { stripBasePath, withBasePath } from "@app/constants/app";
|
||||
import { isSafePostLoginRedirect } from "@app/services/postLoginRedirect";
|
||||
|
||||
// Module-scoped state to reduce global variable usage
|
||||
const recentSpecialByEndpoint: Record<string, number> = {};
|
||||
@@ -21,26 +22,9 @@ const SPECIAL_SUPPRESS_MS = 1500; // brief window to suppress generic duplicate
|
||||
// Mirrors the key in proprietary/auth/springAuthClient.ts; AuthCallback consumes it.
|
||||
const POST_LOGIN_REDIRECT_STORAGE_KEY = "stirling_post_login_path";
|
||||
|
||||
function isSafePostLoginPath(path: string): boolean {
|
||||
if (
|
||||
!path.startsWith("/") ||
|
||||
path.startsWith("//") ||
|
||||
path.startsWith("/\\")
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const lowered = path.toLowerCase();
|
||||
return (
|
||||
!lowered.startsWith("/login") &&
|
||||
!lowered.startsWith("/auth/") &&
|
||||
!lowered.startsWith("/oauth2") &&
|
||||
!lowered.startsWith("/saml2")
|
||||
);
|
||||
}
|
||||
|
||||
function stashPostLoginRedirect(path: string): void {
|
||||
try {
|
||||
if (typeof window === "undefined" || !isSafePostLoginPath(path)) return;
|
||||
if (typeof window === "undefined" || !isSafePostLoginRedirect(path)) return;
|
||||
window.sessionStorage.setItem(POST_LOGIN_REDIRECT_STORAGE_KEY, path);
|
||||
} catch {
|
||||
// sessionStorage unavailable (private mode) — fail open
|
||||
@@ -128,7 +112,11 @@ export async function handleHttpError(error: unknown): Promise<boolean> {
|
||||
console.debug("[httpErrorHandler] 401 detected, redirecting to login");
|
||||
// Spring 302-strips the ?from= query from /login, so stash the return
|
||||
// path in sessionStorage (AuthCallback reads it after SSO round-trip).
|
||||
const currentLocation = window.location.pathname + window.location.search;
|
||||
// Router-relative, not browser-relative: every consumer replays this
|
||||
// through navigate(), which re-applies the basename. Keeping the base
|
||||
// path here yields /app/app/<tool> on a subpath deploy.
|
||||
const currentLocation =
|
||||
stripBasePath(window.location.pathname) + window.location.search;
|
||||
stashPostLoginRedirect(currentLocation);
|
||||
let hadStoredJwt = false;
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isSafePostLoginRedirect } from "@app/services/postLoginRedirect";
|
||||
|
||||
// Core default. Rejects off-origin forms and the auth routes every build has
|
||||
// (/login, /auth/…); Spring SSO routes are the proprietary override's concern.
|
||||
describe("isSafePostLoginRedirect (core base)", () => {
|
||||
it("accepts same-origin router paths", () => {
|
||||
expect(isSafePostLoginRedirect("/editor")).toBe(true);
|
||||
expect(isSafePostLoginRedirect("/compress")).toBe(true);
|
||||
expect(isSafePostLoginRedirect("/editor?foo=bar")).toBe(true);
|
||||
expect(isSafePostLoginRedirect("/oauth/consent?x=1")).toBe(true);
|
||||
expect(isSafePostLoginRedirect("/")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects empty and non-string values", () => {
|
||||
expect(isSafePostLoginRedirect(null)).toBe(false);
|
||||
expect(isSafePostLoginRedirect(undefined)).toBe(false);
|
||||
expect(isSafePostLoginRedirect("")).toBe(false);
|
||||
expect(isSafePostLoginRedirect(42 as unknown)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects off-origin and protocol-relative forms", () => {
|
||||
expect(isSafePostLoginRedirect("//evil.example.com")).toBe(false);
|
||||
expect(isSafePostLoginRedirect("/\\evil.example.com")).toBe(false);
|
||||
expect(isSafePostLoginRedirect("https://evil.example.com")).toBe(false);
|
||||
expect(isSafePostLoginRedirect("editor")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects the universal auth routes so returning back can never loop", () => {
|
||||
expect(isSafePostLoginRedirect("/login")).toBe(false);
|
||||
expect(isSafePostLoginRedirect("/login?next=%2Feditor")).toBe(false);
|
||||
expect(isSafePostLoginRedirect("/auth/callback")).toBe(false);
|
||||
});
|
||||
|
||||
it("leaves the Spring SSO routes to the proprietary override", () => {
|
||||
expect(isSafePostLoginRedirect("/oauth2/authorize")).toBe(true);
|
||||
expect(isSafePostLoginRedirect("/saml2/login")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Is `path` safe to send a user back to after they log in?
|
||||
*/
|
||||
export function isSafePostLoginRedirect(path: unknown): path is string {
|
||||
if (typeof path !== "string" || path.length === 0) return false;
|
||||
if (
|
||||
!path.startsWith("/") ||
|
||||
path.startsWith("//") ||
|
||||
path.startsWith("/\\")
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const lowered = path.toLowerCase();
|
||||
return !lowered.startsWith("/login") && !lowered.startsWith("/auth/");
|
||||
}
|
||||
@@ -55,10 +55,14 @@ describe("workbench session record", () => {
|
||||
});
|
||||
|
||||
describe("editor return path", () => {
|
||||
it("is consumed by the first take", () => {
|
||||
saveEditorReturnPath("/compress?x=1");
|
||||
it("captures the live address bar and is consumed by the first take", () => {
|
||||
// The editor writes its tool route via raw history.pushState, so the save
|
||||
// must read window.location, not a lagging router location.
|
||||
window.history.pushState({}, "", "/compress?x=1");
|
||||
saveEditorReturnPath();
|
||||
expect(takeEditorReturnPath()).toBe("/compress?x=1");
|
||||
expect(takeEditorReturnPath()).toBeNull();
|
||||
window.history.pushState({}, "", "/");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// does not cost the user their workbench. sessionStorage on purpose: per-tab, tabs never clobber.
|
||||
|
||||
import type { StirlingFileStub } from "@app/types/fileContext";
|
||||
import { stripBasePath } from "@app/constants/app";
|
||||
|
||||
const SESSION_KEY = "stirling.workbench.session";
|
||||
/** Bumped when the record's shape or meaning changes, so an old one is discarded rather than
|
||||
@@ -153,8 +154,10 @@ export function isSeedableView(
|
||||
return view !== undefined && SEEDABLE_VIEWS.includes(view);
|
||||
}
|
||||
|
||||
export function saveEditorReturnPath(path: string): void {
|
||||
export function saveEditorReturnPath(): void {
|
||||
try {
|
||||
const path =
|
||||
stripBasePath(window.location.pathname) + window.location.search;
|
||||
sessionStorage.setItem(RETURN_PATH_KEY, path);
|
||||
} catch {
|
||||
// Best-effort: the switch back just lands on the editor root.
|
||||
|
||||
@@ -60,7 +60,8 @@ function fixture(filename: string): string {
|
||||
}
|
||||
|
||||
async function openSamplePdfInViewer(page: import("@playwright/test").Page) {
|
||||
await page.goto("/read");
|
||||
// Not /read: reading collapses the workbench bar these sidebars are toggled from.
|
||||
await page.goto("/");
|
||||
await page.waitForLoadState("domcontentloaded");
|
||||
await page
|
||||
.locator('[data-testid="file-input"]')
|
||||
|
||||
@@ -23,7 +23,8 @@ const SAMPLE_PDF = path.join(
|
||||
);
|
||||
|
||||
async function openViewerWithSample(page: import("@playwright/test").Page) {
|
||||
await page.goto("/read");
|
||||
// Not /read: reading collapses the workbench bar these sidebars are toggled from.
|
||||
await page.goto("/");
|
||||
await page.waitForLoadState("domcontentloaded");
|
||||
await page
|
||||
.locator('[data-testid="file-input"]')
|
||||
|
||||
@@ -27,6 +27,7 @@ async function restoreEnabled(
|
||||
}
|
||||
|
||||
const NO_RESTORE = "this build ships the workbench restore off";
|
||||
const NO_PORTAL = "this build ships no processor to switch to";
|
||||
|
||||
// Switching editor -> processor unmounts every editor provider; the session record
|
||||
// in sessionStorage is what brings the workbench back on return.
|
||||
@@ -73,8 +74,13 @@ test.describe("Workbench survives the editor/processor switch", () => {
|
||||
page.getByRole("radio", { name: /Active Files/i }),
|
||||
).toBeChecked();
|
||||
|
||||
// Out through the sidebar footer switch - the real user path.
|
||||
await page.getByRole("button", { name: "Open PDF Processor" }).click();
|
||||
// Out through the rail's processor mark, the only chrome that offers the switch.
|
||||
const processorMark = page.getByRole("button", { name: /^Processor$/i });
|
||||
test.skip(
|
||||
!(await processorMark.isVisible({ timeout: 5_000 }).catch(() => false)),
|
||||
NO_PORTAL,
|
||||
);
|
||||
await processorMark.click();
|
||||
await expect(page).toHaveURL(/\/processor/, { timeout: 15000 });
|
||||
|
||||
// Split the two halves of the feature: if this fails the writer is at fault,
|
||||
|
||||
@@ -46,7 +46,7 @@ html[data-app-theme="light"] {
|
||||
non-text floor applies. Scheme-independent: a filled badge reads white on
|
||||
either ground. */
|
||||
--c-success-solid: var(--p-green-700);
|
||||
--c-danger-solid: var(--p-red-600);
|
||||
--c-danger-solid: var(--p-red-700);
|
||||
--c-warning-solid: var(--p-amber-700);
|
||||
--c-neutral-solid: var(--p-gray-600);
|
||||
--c-accent-solid: var(--p-blue-600);
|
||||
|
||||
@@ -30,7 +30,12 @@
|
||||
|
||||
--radius-nav: 0.625rem;
|
||||
--nav-gutter: 0.5rem;
|
||||
--nav-rail-w: 3.5rem;
|
||||
/* Every minimised rail is this wide, so they line up as one column of icons. */
|
||||
--nav-rail-w: 3rem;
|
||||
/* Header row, so the rail's brand and a sidebar's wordmark line up. */
|
||||
--nav-header-h: 3rem;
|
||||
--sidebar-w: 16.25rem;
|
||||
--sidebar-collapsed-w: var(--nav-rail-w);
|
||||
|
||||
/* ── Layout sizing ── */
|
||||
--footer-height: 2rem;
|
||||
|
||||
@@ -2,8 +2,8 @@ import { forwardRef, type HTMLAttributes } from "react";
|
||||
import "@app/ui/NavSurface.css";
|
||||
|
||||
export interface NavSurfaceProps extends HTMLAttributes<HTMLDivElement> {
|
||||
/** Element to render; `section`/`aside` when the box is a landmark. */
|
||||
as?: "div" | "section" | "aside";
|
||||
/** Element to render; `section`/`aside`/`nav` when the box is a landmark. */
|
||||
as?: "div" | "section" | "aside" | "nav";
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { WorkbenchType } from "@app/types/workbench";
|
||||
import { getDefaultWorkbench, type WorkbenchType } from "@app/types/workbench";
|
||||
|
||||
export type StartupWorkbench = "viewer" | "fileEditor";
|
||||
|
||||
@@ -7,6 +7,13 @@ export interface StartupNavigationAction {
|
||||
activeFileIndex?: number;
|
||||
}
|
||||
|
||||
/** Several files means the file editor; one or none the viewer. */
|
||||
export function getDefaultWorkbenchForFileCount(
|
||||
fileCount: number,
|
||||
): WorkbenchType {
|
||||
return fileCount > 1 ? "fileEditor" : getDefaultWorkbench();
|
||||
}
|
||||
|
||||
export function getStartupNavigationAction(
|
||||
previousFileCount: number,
|
||||
currentFileCount: number,
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
let pending = false;
|
||||
|
||||
/** Carries "open in reading mode" across an app switch, and deliberately not a reload. */
|
||||
export function requestReaderMode(): void {
|
||||
pending = true;
|
||||
}
|
||||
|
||||
/** True once per request. */
|
||||
export function consumeReaderModeRequest(): boolean {
|
||||
if (!pending) return false;
|
||||
pending = false;
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { withViewTransition } from "@app/utils/viewTransition";
|
||||
|
||||
// The stub carries only the field the helper reads, hence the cast through unknown.
|
||||
type MutableDoc = { startViewTransition?: unknown };
|
||||
const doc = document as unknown as MutableDoc;
|
||||
|
||||
function stubApi(): ReturnType<typeof vi.fn> {
|
||||
const start = vi.fn((cb: () => void) => {
|
||||
cb();
|
||||
return { finished: Promise.resolve() };
|
||||
});
|
||||
doc.startViewTransition = start;
|
||||
return start;
|
||||
}
|
||||
|
||||
function stubReducedMotion(reduced: boolean): void {
|
||||
vi.stubGlobal("matchMedia", (query: string) => ({
|
||||
matches: reduced && query.includes("prefers-reduced-motion"),
|
||||
media: query,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
}));
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
delete doc.startViewTransition;
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("withViewTransition", () => {
|
||||
it("runs the update inside a transition when one is possible", async () => {
|
||||
const start = stubApi();
|
||||
stubReducedMotion(false);
|
||||
const update = vi.fn();
|
||||
|
||||
await withViewTransition(update);
|
||||
|
||||
expect(start).toHaveBeenCalledTimes(1);
|
||||
expect(update).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("skips the transition when the user asked for less motion", async () => {
|
||||
// The state change must still happen - only the animation is dropped.
|
||||
const start = stubApi();
|
||||
stubReducedMotion(true);
|
||||
const update = vi.fn();
|
||||
|
||||
await withViewTransition(update);
|
||||
|
||||
expect(start).not.toHaveBeenCalled();
|
||||
expect(update).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("still applies the update where the API is unavailable", async () => {
|
||||
stubReducedMotion(false);
|
||||
const update = vi.fn();
|
||||
|
||||
await withViewTransition(update);
|
||||
|
||||
expect(update).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -4,21 +4,20 @@ type ViewTransitionDoc = Document & {
|
||||
startViewTransition?: (cb: () => void) => { finished: Promise<void> };
|
||||
};
|
||||
|
||||
/**
|
||||
* Run a state update inside a View Transition so the browser cross-fades
|
||||
* (and morphs any elements sharing a {@code view-transition-name}) between
|
||||
* the before/after DOMs.
|
||||
*
|
||||
* Falls back to a plain synchronous update when the API is unavailable
|
||||
* (Firefox <130, JSDOM, motion-reduced preference).
|
||||
*/
|
||||
/** Runs a state update in a View Transition, plainly where that is unavailable. */
|
||||
export function withViewTransition(update: () => void): Promise<void> {
|
||||
if (typeof document === "undefined") {
|
||||
update();
|
||||
return Promise.resolve();
|
||||
}
|
||||
// Callers don't each check: reduced motion still gets the state change.
|
||||
const reduced =
|
||||
typeof window !== "undefined" &&
|
||||
typeof window.matchMedia === "function" &&
|
||||
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
|
||||
const doc = document as ViewTransitionDoc;
|
||||
if (doc.startViewTransition) {
|
||||
if (doc.startViewTransition && !reduced) {
|
||||
return doc.startViewTransition(() => flushSync(update)).finished;
|
||||
}
|
||||
update();
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { Logo } from "@app/ui/Logo";
|
||||
import { type AppSwitcherProps } from "@core/components/shared/AppSwitcher";
|
||||
|
||||
/**
|
||||
* Desktop inherits proprietary's layers but does not ship the portal (see
|
||||
* desktop/routes/adminRouteExtensions), so there's nothing to switch to —
|
||||
* shadow the brand header back to a plain logo. (Also avoids the desktop
|
||||
* bundle referencing @portal via the proprietary switcher's imports.)
|
||||
*/
|
||||
export function AppSwitcher({ collapsed }: AppSwitcherProps) {
|
||||
return (
|
||||
<Logo
|
||||
variant={collapsed ? "iconOnly" : "iconAndText"}
|
||||
iconHeight="1.6rem"
|
||||
textHeight="1.3rem"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
/** Desktop inherits proprietary's app but never ships the portal. */
|
||||
export const HAS_PORTAL = false;
|
||||
@@ -7,8 +7,11 @@ import { PortalSearchBar } from "@portal/components/PortalSearchBar";
|
||||
import { useUI } from "@portal/contexts/UIContext";
|
||||
import { MenuIcon, SearchIcon } from "@portal/components/icons";
|
||||
import { Logo } from "@app/ui/Logo";
|
||||
import "@app/components/layout/WorkspaceFrame.css";
|
||||
import { QuickNavHostBridge } from "@app/components/shared/quickNav/QuickNavHostBridge";
|
||||
import "@portal/components/AppShell.css";
|
||||
import { NotificationBell } from "@app/components/notifications/NotificationBell";
|
||||
import { useIsPhone } from "@app/hooks/useIsMobile";
|
||||
|
||||
/**
|
||||
* Compact header shown only under the mobile breakpoint (CSS-hidden on
|
||||
@@ -58,8 +61,10 @@ function MobileTopbar() {
|
||||
* prop-free.
|
||||
*/
|
||||
export function AppShell({ children }: { children: ReactNode }) {
|
||||
const { mobileNavOpen, closeMobileNav } = useUI();
|
||||
const { mobileNavOpen, closeMobileNav, openSettings } = useUI();
|
||||
const { pathname } = useLocation();
|
||||
// Below this width the rail, and the bell it carries, is gone.
|
||||
const isPhone = useIsPhone();
|
||||
|
||||
// Navigating (tap on a nav row, back button, deep link) always dismisses the
|
||||
// drawer. Depends on pathname only: the close fn's identity changes with any
|
||||
@@ -79,7 +84,11 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
|
||||
return (
|
||||
<div className="portal-shell">
|
||||
<Sidebar />
|
||||
{/* portalAccess: being here is proof the processor is available. */}
|
||||
<QuickNavHostBridge portalAccess onOpenSettings={() => openSettings()} />
|
||||
<div className="workspace-frame">
|
||||
<Sidebar />
|
||||
</div>
|
||||
{mobileNavOpen && (
|
||||
<div
|
||||
className="portal-shell__scrim"
|
||||
@@ -90,9 +99,12 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
<div className="portal-shell__main">
|
||||
<MobileTopbar />
|
||||
<PortalSearchBar />
|
||||
<div className="portal-shell__notifications">
|
||||
<NotificationBell />
|
||||
</div>
|
||||
{/* Phone only: above that the rail carries it, and this would be a second. */}
|
||||
{isPhone && (
|
||||
<div className="portal-shell__notifications">
|
||||
<NotificationBell />
|
||||
</div>
|
||||
)}
|
||||
<main className="portal-shell__view">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,31 +11,9 @@ import {
|
||||
import { type EditorInstance } from "@portal/api/editorDeploy";
|
||||
import { DownloadEditorModal } from "@portal/components/DownloadEditorModal";
|
||||
import "@portal/theme/surface.css";
|
||||
import { BrandTile } from "@app/components/shared/BrandTile";
|
||||
import "@portal/components/EditorStatusCard.css";
|
||||
|
||||
/** The Stirling brand mark, drawn at the hero size. Decorative. */
|
||||
function StirlingMark() {
|
||||
return (
|
||||
<svg
|
||||
className="portal-editor-hero__mark"
|
||||
viewBox="0 0 256 256"
|
||||
fill="none"
|
||||
aria-hidden
|
||||
>
|
||||
<rect width="256" height="256" rx="58" fill="var(--c-brand-mark)" />
|
||||
<path
|
||||
d="M39.2638 127.834L155.374 32L155.375 121.499L39.2638 217.333L39.2638 127.834Z"
|
||||
fill="white"
|
||||
/>
|
||||
<path
|
||||
d="M159 124.5L159 88.5L216.728 38.4472L216.728 128.052L100.479 224L100.479 172L159 124.5Z"
|
||||
fill="white"
|
||||
fillOpacity="0.6"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** The instance to headline: the busiest healthy one, else the first. */
|
||||
function primaryInstance(instances: EditorInstance[]): EditorInstance | null {
|
||||
if (instances.length === 0) return null;
|
||||
@@ -126,7 +104,7 @@ export function EditorStatusCard({ footer }: EditorStatusCardProps) {
|
||||
>
|
||||
<div className="portal-editor-hero__row">
|
||||
<div className="portal-editor-hero__logo">
|
||||
<StirlingMark />
|
||||
<BrandTile className="portal-editor-hero__mark" />
|
||||
</div>
|
||||
|
||||
<div className="portal-editor-hero__info">
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
/* Unpainted strip at the top of the main column. Height matches the sidebar's
|
||||
logo row (.portal-sidebar__logo, 51px) so the search lines up with the brand. */
|
||||
/* Unpainted strip at the top of the main column, matching the sidebar header's height. */
|
||||
.portal-searchbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
.portal-sidebar {
|
||||
width: 15rem;
|
||||
width: var(--sidebar-w);
|
||||
height: 100vh;
|
||||
height: 100dvh; /* track mobile browser chrome */
|
||||
background: var(--c-bg);
|
||||
/* Matches the editor's sidebar: one solid panel with a rule on the content side. */
|
||||
background: var(--c-surface);
|
||||
border-inline-end: 1px solid var(--c-border-subtle);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
@@ -21,10 +23,6 @@
|
||||
|
||||
/* Nav labels stay on one line and are clipped by the narrowing rail so they
|
||||
reveal/hide cleanly as the width animates rather than wrapping. */
|
||||
.portal-sidebar__nav,
|
||||
.portal-sidebar__footer {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
.portal-sidebar .sui-navitem__label,
|
||||
.portal-sidebar__section-label {
|
||||
white-space: nowrap;
|
||||
@@ -34,6 +32,8 @@
|
||||
.portal-sidebar__close {
|
||||
display: none;
|
||||
flex-shrink: 0;
|
||||
/* Trailing edge: on mobile this is the row's only control. */
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.portal-sidebar__collapse {
|
||||
@@ -78,23 +78,16 @@
|
||||
|
||||
/* ---- Collapsed icon rail (desktop only) ---- */
|
||||
.portal-sidebar[data-collapsed] {
|
||||
width: var(--nav-rail-w);
|
||||
}
|
||||
.portal-sidebar[data-collapsed] .portal-sidebar__logo {
|
||||
flex-direction: column;
|
||||
height: auto;
|
||||
padding: 0.5rem 0;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
.portal-sidebar[data-collapsed] .portal-sidebar__collapse {
|
||||
margin-left: 0;
|
||||
width: var(--sidebar-collapsed-w);
|
||||
}
|
||||
/* Flush, like the nav: the selected row runs the full width of the rail. */
|
||||
.portal-sidebar[data-collapsed] .portal-sidebar__nav {
|
||||
padding-inline: 0.375rem;
|
||||
padding-inline: 0;
|
||||
}
|
||||
/* Stretch, not centre: a centred group shrinks to its content, so rows can't fill the rail. */
|
||||
.portal-sidebar[data-collapsed] .portal-sidebar__section {
|
||||
padding-inline: 0;
|
||||
align-items: center;
|
||||
align-items: stretch;
|
||||
}
|
||||
.portal-sidebar[data-collapsed] .portal-sidebar__section-label {
|
||||
display: none;
|
||||
@@ -111,23 +104,16 @@
|
||||
margin-inline: 0;
|
||||
padding-inline: 0;
|
||||
width: 100%;
|
||||
}
|
||||
/* Neutralise the active-item edge-bar geometry (negative margins + overhang)
|
||||
that assumes the full-width rail. */
|
||||
.portal-sidebar[data-collapsed] .sui-navitem.is-active {
|
||||
width: 100%;
|
||||
margin-inline: 0;
|
||||
border-left: none;
|
||||
border-radius: 0.5rem;
|
||||
padding-left: 0;
|
||||
/* A square target, so it takes the rail's radius rather than NavItem's pill. */
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
.portal-sidebar[data-collapsed] .portal-sidebar__footer {
|
||||
margin-inline: 0.375rem;
|
||||
}
|
||||
|
||||
.portal-sidebar__logo {
|
||||
height: 3.1875rem; /* 51px */
|
||||
padding: 0 0.875rem;
|
||||
.portal-sidebar__header {
|
||||
height: var(--nav-header-h);
|
||||
padding: 0 var(--nav-gutter);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
@@ -136,35 +122,58 @@
|
||||
.portal-sidebar__nav {
|
||||
flex: 0 1 auto;
|
||||
overflow-y: auto;
|
||||
padding: 0.75rem 0.625rem;
|
||||
overflow-x: clip;
|
||||
/* No inline inset above the rows, so a row is full width and needs no bleed past the clip. */
|
||||
padding: var(--nav-gutter) 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
gap: var(--nav-gutter);
|
||||
}
|
||||
|
||||
.portal-sidebar .sui-navitem {
|
||||
margin-inline: 0.25rem;
|
||||
padding-inline: 0.625rem;
|
||||
margin-inline: 0;
|
||||
padding-inline: 1.75rem;
|
||||
}
|
||||
|
||||
.portal-sidebar .sui-navitem.is-active {
|
||||
width: calc(100% + 0.75rem);
|
||||
margin-inline: -0.375rem;
|
||||
/* The selected view, marked as the rail marks the current app: a knocked-out solid block. */
|
||||
.portal-sidebar .sui-navitem.is-active,
|
||||
.portal-sidebar .sui-navitem.is-active:hover {
|
||||
background: var(--c-text);
|
||||
color: var(--c-surface);
|
||||
border-radius: 0;
|
||||
border-left: 3px solid var(--c-primary);
|
||||
padding-left: calc(1.25rem - 3px);
|
||||
}
|
||||
|
||||
/* On a dark ground full ink is white, so mix the block back toward the surface. */
|
||||
[data-theme="dark"] .portal-sidebar .sui-navitem.is-active,
|
||||
[data-theme="dark"] .portal-sidebar .sui-navitem.is-active:hover,
|
||||
html[data-app-theme="midnight"] .portal-sidebar .sui-navitem.is-active,
|
||||
html[data-app-theme="midnight"] .portal-sidebar .sui-navitem.is-active:hover,
|
||||
[data-mantine-color-scheme="dark"] .portal-sidebar .sui-navitem.is-active,
|
||||
[data-mantine-color-scheme="dark"]
|
||||
.portal-sidebar
|
||||
.sui-navitem.is-active:hover {
|
||||
background: color-mix(in srgb, var(--c-text) 80%, var(--c-surface));
|
||||
color: var(--c-surface);
|
||||
}
|
||||
|
||||
.portal-sidebar__section {
|
||||
padding: 0.5rem 0.375rem 0.375rem;
|
||||
padding: 0.5rem 0 0.375rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
/* Flattened here; two classes deep to beat .sui-nav-surface regardless of load order. */
|
||||
.portal-sidebar .sui-nav-surface {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.portal-sidebar__section-label {
|
||||
margin: 0;
|
||||
padding: 0 0.5rem;
|
||||
/* Its own 0.5rem, plus the inset the nav and section no longer add. */
|
||||
padding: 0 1.375rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
@@ -181,4 +190,18 @@
|
||||
only positions it. */
|
||||
.portal-sidebar__footer {
|
||||
margin: 0 0.625rem 0.75rem;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Fills the frame, not the viewport: a 100vh sticky column would overhang it. */
|
||||
.workspace-frame .portal-sidebar {
|
||||
height: 100%;
|
||||
position: static;
|
||||
}
|
||||
|
||||
@media (max-width: 48rem) {
|
||||
.workspace-frame .portal-sidebar {
|
||||
position: fixed;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
import { useMediaQuery } from "@mantine/hooks";
|
||||
import { Tooltip } from "@mantine/core";
|
||||
import { ActionIcon, NavItem, NavSurface } from "@app/ui";
|
||||
import { BrandSwitcher } from "@app/components/shared/BrandSwitcher";
|
||||
import { SidebarToggleButton } from "@app/components/shared/SidebarToggleButton";
|
||||
import { Logo } from "@app/ui/Logo";
|
||||
import { NavFooter } from "@app/components/shared/navFooter/NavFooter";
|
||||
import { useAccountIdentity } from "@app/hooks/useAccountIdentity";
|
||||
import { useFreeCreditsSummary } from "@portal/hooks/useFreeCreditsSummary";
|
||||
import { useOpenPlan } from "@portal/hooks/useOpenPlan";
|
||||
import { SidebarToggleIcon } from "@app/components/shared/SidebarToggleIcon";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useView, type ViewId } from "@portal/contexts/ViewContext";
|
||||
import { useUI } from "@portal/contexts/UIContext";
|
||||
import { LinkAccountFooterItem } from "@portal/components/LinkAccountFooterItem";
|
||||
import { EDITOR_URL, EDITOR_IS_SAME_APP } from "@portal/auth/editorUrl";
|
||||
import { EDITOR_BASENAME } from "@app/routes/editorBasename";
|
||||
import { takeEditorReturnPath } from "@app/services/workbenchSession";
|
||||
import { CloseIcon } from "@portal/components/icons";
|
||||
import {
|
||||
GROUP_PROCESSOR,
|
||||
@@ -30,19 +26,17 @@ const NAV_SECTIONS: NavGroup[] = [
|
||||
];
|
||||
|
||||
/** Must match the shell breakpoint in AppShell.css / Sidebar.css. */
|
||||
const MOBILE_QUERY = "(max-width: 48rem)";
|
||||
export const MOBILE_QUERY = "(max-width: 48rem)";
|
||||
|
||||
export function Sidebar() {
|
||||
const { activeView, setActiveView } = useView();
|
||||
const {
|
||||
openSettings,
|
||||
mobileNavOpen,
|
||||
closeMobileNav,
|
||||
sidebarCollapsed,
|
||||
toggleSidebarCollapsed,
|
||||
} = useUI();
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const isMobile = useMediaQuery(MOBILE_QUERY, false, {
|
||||
getInitialValueInEffect: false,
|
||||
});
|
||||
@@ -54,14 +48,6 @@ export function Sidebar() {
|
||||
// off-canvas drawer, so the icon-rail state never applies there.
|
||||
const collapsed = sidebarCollapsed && !isMobile;
|
||||
|
||||
// Editor and portal are one SPA when the editor serves this origin's root, so
|
||||
// the switch stays client-side; an absolute EDITOR_URL (dev cross-app setup)
|
||||
// needs a full page load.
|
||||
const goToEditor = () => {
|
||||
if (EDITOR_IS_SAME_APP) navigate(takeEditorReturnPath() ?? EDITOR_BASENAME);
|
||||
else window.location.href = EDITOR_URL;
|
||||
};
|
||||
|
||||
// Procurement is no longer a nav tab — it lives on Home as the deal-status hero and expands into
|
||||
// a takeover modal (matching the marketing prototype).
|
||||
|
||||
@@ -107,25 +93,13 @@ export function Sidebar() {
|
||||
// Off-canvas on mobile: remove from the tab order and accessibility tree.
|
||||
inert={isMobile && !mobileNavOpen}
|
||||
>
|
||||
<div className="portal-sidebar__logo">
|
||||
<BrandSwitcher
|
||||
current="processor"
|
||||
onSwitch={goToEditor}
|
||||
collapsed={collapsed}
|
||||
/>
|
||||
<div className="portal-sidebar__header">
|
||||
{!collapsed && <Logo variant="textOnly" textHeight="1.3rem" />}
|
||||
|
||||
<ActionIcon
|
||||
variant="tertiary"
|
||||
className="portal-sidebar__collapse"
|
||||
aria-label={
|
||||
collapsed
|
||||
? t("fileSidebar.expand", "Expand sidebar")
|
||||
: t("fileSidebar.collapse", "Collapse sidebar")
|
||||
}
|
||||
onClick={toggleSidebarCollapsed}
|
||||
>
|
||||
<SidebarToggleIcon size={18} />
|
||||
</ActionIcon>
|
||||
<SidebarToggleButton
|
||||
collapsed={collapsed}
|
||||
onToggle={toggleSidebarCollapsed}
|
||||
/>
|
||||
|
||||
<ActionIcon
|
||||
variant="tertiary"
|
||||
@@ -158,10 +132,9 @@ export function Sidebar() {
|
||||
className="portal-sidebar__footer"
|
||||
displayName={displayName}
|
||||
profilePictureUrl={profilePictureUrl}
|
||||
onOpenSettings={openSettings}
|
||||
showAccount={false}
|
||||
credits={credits}
|
||||
onOpenPlan={openPlan ?? undefined}
|
||||
otherApp={{ app: "editor", onOpen: goToEditor }}
|
||||
accountExtras={<LinkAccountFooterItem />}
|
||||
collapsed={collapsed}
|
||||
/>
|
||||
|
||||
@@ -18,6 +18,8 @@ const MobileScannerPage = lazy(() => import("@app/pages/MobileScannerPage"));
|
||||
const MobileSignPage = lazy(() => import("@app/pages/MobileSignPage"));
|
||||
import { WATCHED_FOLDERS_ENABLED } from "@app/constants/featureFlags";
|
||||
import { getAdminRouteExtensions } from "@app/routes/adminRouteExtensions";
|
||||
import { AppFrame } from "@app/components/layout/AppFrame";
|
||||
import { NoAppChrome } from "@app/components/layout/NoAppChrome";
|
||||
import { RootGate } from "@app/routes/RootGate";
|
||||
|
||||
// Import global styles
|
||||
@@ -80,40 +82,52 @@ export default function App() {
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Admin-only route-set (the portal): its own top-level shell, mounted
|
||||
before the catch-all. Absent from core/desktop builds (empty stub). */}
|
||||
{getAdminRouteExtensions()}
|
||||
{/* Both apps, under a shared frame so the rail renders once outside them. */}
|
||||
<Route element={<AppFrame />}>
|
||||
{/* The portal: its own shell, before the catch-all. An empty stub in core. */}
|
||||
{getAdminRouteExtensions()}
|
||||
|
||||
{/* All other routes need AppProviders for backend integration.
|
||||
RootGate makes "/" route by role BEFORE any of it mounts, so a user
|
||||
bound for the processor never boots the editor on the way. */}
|
||||
<Route
|
||||
path="*"
|
||||
element={
|
||||
<RootGate>
|
||||
<AppProviders>
|
||||
<AppLayout>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
{/* Self-hosted has no signup - accounts are created by an
|
||||
admin. Old links land on login instead. */}
|
||||
<Route
|
||||
path="/signup"
|
||||
element={<Navigate to="/login" replace />}
|
||||
/>
|
||||
<Route path="/auth/callback" element={<AuthCallback />} />
|
||||
<Route path="/invite/:token" element={<InviteAccept />} />
|
||||
<Route path="/share/:token" element={<ShareLinkPage />} />
|
||||
{/* The editor and its tool routes - Landing handles auth logic */}
|
||||
<Route path="/*" element={<Landing />} />
|
||||
</Routes>
|
||||
<Onboarding />
|
||||
{WATCHED_FOLDERS_ENABLED && <WatchedFoldersRegistration />}
|
||||
</AppLayout>
|
||||
</AppProviders>
|
||||
</RootGate>
|
||||
}
|
||||
/>
|
||||
{/* All other routes need AppProviders for backend integration. RootGate
|
||||
routes "/" by role before any of it mounts. */}
|
||||
<Route
|
||||
path="*"
|
||||
element={
|
||||
<RootGate>
|
||||
<AppProviders>
|
||||
<AppLayout>
|
||||
<Routes>
|
||||
{/* Not the app: no rail over any of these, ever. */}
|
||||
<Route element={<NoAppChrome />}>
|
||||
<Route path="/login" element={<Login />} />
|
||||
{/* Self-hosted has no signup: old links land on login. */}
|
||||
<Route
|
||||
path="/signup"
|
||||
element={<Navigate to="/login" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="/auth/callback"
|
||||
element={<AuthCallback />}
|
||||
/>
|
||||
<Route
|
||||
path="/invite/:token"
|
||||
element={<InviteAccept />}
|
||||
/>
|
||||
<Route
|
||||
path="/share/:token"
|
||||
element={<ShareLinkPage />}
|
||||
/>
|
||||
</Route>
|
||||
{/* The editor and its tool routes - Landing handles auth logic */}
|
||||
<Route path="/*" element={<Landing />} />
|
||||
</Routes>
|
||||
<Onboarding />
|
||||
{WATCHED_FOLDERS_ENABLED && <WatchedFoldersRegistration />}
|
||||
</AppLayout>
|
||||
</AppProviders>
|
||||
</RootGate>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
</Routes>
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
@@ -16,6 +16,7 @@ import { AxiosError, type AxiosRequestConfig } from "axios";
|
||||
import { getSpringAuthConfig } from "@app/auth/config";
|
||||
import { type OAuthProvider } from "@app/auth/spring/oauthTypes";
|
||||
import { resetOAuthState } from "@app/auth/spring/oauthStorage";
|
||||
import { isSafePostLoginRedirect } from "@app/services/postLoginRedirect";
|
||||
import type {
|
||||
AuthUser as User,
|
||||
AuthSession as Session,
|
||||
@@ -100,23 +101,10 @@ function persistRedirectPath(path: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
// Same-origin relative path, not pointing at auth plumbing. Rejects protocol-relative
|
||||
// URLs to guard against open-redirect abuse if the stored value is tampered with.
|
||||
export function isSafePostLoginRedirect(path: unknown): path is string {
|
||||
if (typeof path !== "string" || path.length === 0) return false;
|
||||
if (!path.startsWith("/") || path.startsWith("//")) return false;
|
||||
if (path.startsWith("/\\")) return false;
|
||||
const lowered = path.toLowerCase();
|
||||
if (
|
||||
lowered.startsWith("/login") ||
|
||||
lowered.startsWith("/auth/") ||
|
||||
lowered.startsWith("/oauth2") ||
|
||||
lowered.startsWith("/saml2")
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
// The safe-return-path rule lives in the shared @app/services/postLoginRedirect
|
||||
// extension point (proprietary override adds the Spring SSO routes). Re-exported
|
||||
// here so existing importers via @app/auth keep resolving it.
|
||||
export { isSafePostLoginRedirect };
|
||||
|
||||
export function setPostLoginRedirectPath(
|
||||
path: string | null | undefined,
|
||||
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
import { getSupabaseClient } from "@app/auth/supabase/supabaseClient";
|
||||
import { AuthContext } from "@app/auth/context";
|
||||
import { isAdminRole } from "@app/auth/roles";
|
||||
import { getApiBaseUrl } from "@app/services/apiClientConfig";
|
||||
import {
|
||||
defaultTranslate,
|
||||
type AuthContextValue,
|
||||
@@ -154,14 +155,23 @@ export function SupabaseAuthProvider({
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
// Same API base the rest of the app uses: SaaS serves the frontend and the
|
||||
// API from different hosts, so a root-relative path never reaches /me.
|
||||
const apiBase = (getApiBaseUrl() || "").replace(/\/+$/, "");
|
||||
const meUrl = `${apiBase}/api/v1/auth/me`;
|
||||
const loadAccess = () => {
|
||||
void fetch("/api/v1/auth/me", {
|
||||
void fetch(meUrl, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: "application/json",
|
||||
},
|
||||
})
|
||||
.then((res) => (res.ok ? res.json() : null))
|
||||
.then((res) => {
|
||||
// Must throw, not resolve null: swallowing a non-ok leaves
|
||||
// portalAccess undefined and hangs the portal gate on a spinner.
|
||||
if (!res.ok) throw new Error(`auth/me responded ${res.status}`);
|
||||
return res.json();
|
||||
})
|
||||
.then(
|
||||
(
|
||||
data: {
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, waitFor } from "@testing-library/react";
|
||||
import { useContext } from "react";
|
||||
|
||||
const h = vi.hoisted(() => ({ apiBase: "/" }));
|
||||
|
||||
vi.mock("@app/services/apiClientConfig", () => ({
|
||||
getApiBaseUrl: () => h.apiBase,
|
||||
}));
|
||||
|
||||
const sbSession = {
|
||||
access_token: "supabase-token",
|
||||
user: {
|
||||
id: "u1",
|
||||
email: "user@example.com",
|
||||
is_anonymous: false,
|
||||
app_metadata: {},
|
||||
user_metadata: {},
|
||||
},
|
||||
};
|
||||
|
||||
vi.mock("@app/auth/supabase/supabaseClient", () => ({
|
||||
getSupabaseClient: () => ({
|
||||
auth: {
|
||||
getSession: () => Promise.resolve({ data: { session: sbSession } }),
|
||||
onAuthStateChange: () => ({
|
||||
data: { subscription: { unsubscribe: () => {} } },
|
||||
}),
|
||||
refreshSession: () => Promise.resolve({ data: {}, error: null }),
|
||||
signOut: () => Promise.resolve({ error: null }),
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
import { SupabaseAuthProvider } from "@app/auth/supabase/UseSession";
|
||||
import { AuthContext } from "@app/auth/context";
|
||||
|
||||
function Probe() {
|
||||
const v = useContext(AuthContext);
|
||||
return (
|
||||
<>
|
||||
<span data-testid="access">{String(v?.portalAccess)}</span>
|
||||
{/* Raw, un-defaulted value: this is what SaasPortalGate reads to decide
|
||||
"access not known yet" vs "denied". undefined = spinner forever. */}
|
||||
<span data-testid="raw">{String(v?.user?.portalAccess)}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const mount = () =>
|
||||
render(
|
||||
<SupabaseAuthProvider>
|
||||
<Probe />
|
||||
</SupabaseAuthProvider>,
|
||||
);
|
||||
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
describe("supabase provider portalAccess lookup", () => {
|
||||
// SaaS serves the frontend and the API from different hosts; a root-relative
|
||||
// path silently missed /me, so a granted non-admin was denied the Processor.
|
||||
it("calls /me on the configured API base, not the page origin", async () => {
|
||||
h.apiBase = "https://api.example.com";
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ user: { portalAccess: true } }),
|
||||
});
|
||||
mount();
|
||||
await waitFor(() =>
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://api.example.com/api/v1/auth/me",
|
||||
expect.anything(),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a same-origin base as a single leading slash", async () => {
|
||||
h.apiBase = "/";
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ user: { portalAccess: true } }),
|
||||
});
|
||||
mount();
|
||||
await waitFor(() =>
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"/api/v1/auth/me",
|
||||
expect.anything(),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("grants access when /me says so", async () => {
|
||||
h.apiBase = "https://api.example.com";
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ user: { portalAccess: true } }),
|
||||
});
|
||||
const { getByTestId } = mount();
|
||||
await waitFor(() => expect(getByTestId("access").textContent).toBe("true"));
|
||||
});
|
||||
|
||||
// A non-ok used to resolve to null and return early, leaving the raw
|
||||
// portalAccess undefined forever - SaasPortalGate reads that as "still
|
||||
// loading" and hangs on a spinner instead of falling back.
|
||||
it("resolves the raw portalAccess when /me returns non-ok", async () => {
|
||||
h.apiBase = "https://api.example.com";
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 401,
|
||||
json: () => Promise.resolve({}),
|
||||
});
|
||||
const { getByTestId } = mount();
|
||||
await waitFor(() => expect(getByTestId("raw").textContent).toBe("false"));
|
||||
});
|
||||
|
||||
it("leaves the raw portalAccess defined when the request rejects", async () => {
|
||||
h.apiBase = "https://api.example.com";
|
||||
fetchMock.mockRejectedValue(new Error("network down"));
|
||||
const { getByTestId } = mount();
|
||||
await waitFor(() => expect(getByTestId("raw").textContent).toBe("false"));
|
||||
});
|
||||
});
|
||||
@@ -1,35 +0,0 @@
|
||||
import { Logo } from "@app/ui/Logo";
|
||||
import { BrandSwitcher } from "@app/components/shared/BrandSwitcher";
|
||||
import { type AppSwitcherProps } from "@core/components/shared/AppSwitcher";
|
||||
import { useOtherAppSwitch } from "@app/hooks/useOtherAppSwitch";
|
||||
|
||||
/**
|
||||
* Sidebar brand header for builds that ship the processor. When this user can
|
||||
* open it, the Stirling logo doubles as the editor⇄processor switcher: the mark
|
||||
* morphs into a chevron and opens the switch menu (the same BrandSwitcher the
|
||||
* processor sidebar uses). Users without access get a plain logo.
|
||||
*
|
||||
* The access gate lives in {@link useOtherAppSwitch} so this header and the
|
||||
* sidebar footer's "Open PDF Processor" row are driven by one answer.
|
||||
*/
|
||||
export function AppSwitcher({ collapsed }: AppSwitcherProps) {
|
||||
const otherApp = useOtherAppSwitch();
|
||||
|
||||
if (!otherApp) {
|
||||
return (
|
||||
<Logo
|
||||
variant={collapsed ? "iconOnly" : "iconAndText"}
|
||||
iconHeight="1.6rem"
|
||||
textHeight="1.3rem"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<BrandSwitcher
|
||||
current="editor"
|
||||
onSwitch={otherApp.onOpen}
|
||||
collapsed={collapsed}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -6,15 +6,10 @@ import type {
|
||||
PortalEntityItems,
|
||||
PortalEntityScopeId,
|
||||
} from "@portal/search/entitySearch";
|
||||
import { HAS_PORTAL } from "@app/routes/hasPortal";
|
||||
|
||||
type EntitySearchModule = typeof import("@portal/search/entitySearch");
|
||||
|
||||
// Mirrors the admin-route seam's gate: the portal route-set is only mounted in
|
||||
// dev and in builds made with VITE_INCLUDE_PORTAL=true, so the search must not
|
||||
// fetch or offer entities that have nowhere to open.
|
||||
const includePortal =
|
||||
import.meta.env.VITE_INCLUDE_PORTAL === "true" || import.meta.env.DEV;
|
||||
|
||||
const NO_GROUPS: SuperSearchGroup[] = [];
|
||||
const NO_SCOPES: readonly PortalEntityScopeId[] = [];
|
||||
|
||||
@@ -41,7 +36,8 @@ export function useProcessorEntityGroups(
|
||||
): SuperSearchGroup[] {
|
||||
const [mod, setMod] = useState<EntitySearchModule | null>(null);
|
||||
const modRef = useRef<EntitySearchModule | null>(null);
|
||||
const active = enabled && includePortal;
|
||||
// Without the portal these entities have nowhere to open, so don't fetch them.
|
||||
const active = enabled && HAS_PORTAL;
|
||||
const hasQuery = trimmed.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -3,15 +3,10 @@ import { PORTAL_BASENAME } from "@app/routes/portalBasename";
|
||||
// the lazy portal chunk into the main bundle the way @portal/* values would.
|
||||
import { usersCapabilities } from "@app/portal/usersCapabilities";
|
||||
import type { ProcessorSearchEntry } from "@core/data/processorSearchIndex";
|
||||
import { HAS_PORTAL } from "@app/routes/hasPortal";
|
||||
|
||||
export type { ProcessorSearchEntry };
|
||||
|
||||
// Mirrors the admin-route seam's gate: the portal route-set is only mounted in
|
||||
// dev and in builds made with VITE_INCLUDE_PORTAL=true, so the search must not
|
||||
// offer destinations that would 404 elsewhere.
|
||||
const includePortal =
|
||||
import.meta.env.VITE_INCLUDE_PORTAL === "true" || import.meta.env.DEV;
|
||||
|
||||
/**
|
||||
* The portal's in-app views. Deliberately a static mirror of the portal's nav
|
||||
* (labels via the same portal.nav.* keys its sidebar uses) rather than an
|
||||
@@ -91,7 +86,8 @@ const VIEWS: ProcessorSearchEntry[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export const PROCESSOR_SEARCH_INDEX: ProcessorSearchEntry[] = includePortal
|
||||
// Empty without the portal: these destinations would 404.
|
||||
export const PROCESSOR_SEARCH_INDEX: ProcessorSearchEntry[] = HAS_PORTAL
|
||||
? VIEWS
|
||||
: [];
|
||||
|
||||
|
||||
@@ -7,9 +7,10 @@ const mocks = vi.hoisted(() => ({
|
||||
portalAccess: true,
|
||||
}));
|
||||
|
||||
// The hook reads window.location for the return path (not useLocation), because
|
||||
// the editor's raw history.pushState leaves react-router's location stale.
|
||||
vi.mock("react-router-dom", () => ({
|
||||
useNavigate: () => mocks.navigate,
|
||||
useLocation: () => ({ pathname: "/compress", search: "?mode=fast" }),
|
||||
}));
|
||||
vi.mock("@app/auth/context", () => ({
|
||||
useAuth: () => ({ portalAccess: mocks.portalAccess }),
|
||||
@@ -27,6 +28,7 @@ beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
vi.clearAllMocks();
|
||||
mocks.portalAccess = true;
|
||||
window.history.pushState({}, "", "/");
|
||||
});
|
||||
|
||||
describe("useOtherAppSwitch", () => {
|
||||
@@ -45,6 +47,7 @@ describe("useOtherAppSwitch", () => {
|
||||
});
|
||||
|
||||
it("records where to return to, then navigates to the processor", () => {
|
||||
window.history.pushState({}, "", "/compress?mode=fast");
|
||||
const { result } = renderHook(() => useOtherAppSwitch());
|
||||
result.current?.onOpen();
|
||||
mocks.requestNavigation.mock.calls[0][0]();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "@app/auth/context";
|
||||
import { useNavigationActions } from "@app/contexts/NavigationContext";
|
||||
import { PORTAL_BASENAME } from "@app/routes/portalBasename";
|
||||
@@ -12,7 +12,6 @@ import { type NavFooterAppLink } from "@app/components/shared/navFooter/NavFoote
|
||||
export function useOtherAppSwitch(): NavFooterAppLink | null {
|
||||
const { portalAccess } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { actions } = useNavigationActions();
|
||||
if (!portalAccess) return null;
|
||||
return {
|
||||
@@ -20,7 +19,7 @@ export function useOtherAppSwitch(): NavFooterAppLink | null {
|
||||
onOpen: () =>
|
||||
// Through the guard, so unsaved edits get the same warning as any other navigation.
|
||||
actions.requestNavigation(() => {
|
||||
saveEditorReturnPath(location.pathname + location.search);
|
||||
saveEditorReturnPath();
|
||||
navigate(PORTAL_BASENAME);
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect } from "react";
|
||||
import { Navigate, useLocation, useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "@app/auth/UseSession";
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
import { useSuppressQuickNavRail } from "@app/contexts/QuickNavHostContext";
|
||||
import HomePage from "@app/pages/HomePage";
|
||||
import { useBackendProbe } from "@app/hooks/useBackendProbe";
|
||||
import { EDITOR_BASENAME } from "@app/routes/editorBasename";
|
||||
@@ -27,6 +28,9 @@ export default function Landing() {
|
||||
|
||||
const loading = authLoading || configLoading || backendProbe.loading;
|
||||
|
||||
// The backend-down screen is not the app. Loading is: it resolves in a moment.
|
||||
useSuppressQuickNavRail(!session && backendProbe.status !== "up");
|
||||
|
||||
// Debug: Track Landing component lifecycle
|
||||
useEffect(() => {
|
||||
const mountId = Math.random().toString(36).substring(7);
|
||||
|
||||
@@ -234,7 +234,10 @@ describe("Login", () => {
|
||||
);
|
||||
};
|
||||
|
||||
afterEach(() => window.history.replaceState({}, "", "/"));
|
||||
afterEach(() => {
|
||||
window.history.replaceState({}, "", "/");
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
it("returns to where the user came from", async () => {
|
||||
signedIn();
|
||||
@@ -247,6 +250,24 @@ describe("Login", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// A full-page 401 redirect drops router state and Spring can strip ?from=,
|
||||
// leaving the return path only in the sessionStorage stash. Without reading
|
||||
// it here a processor user refreshing /editor falls through to the role
|
||||
// router and lands on the processor.
|
||||
it("returns to the stashed path when there is no ?from=", async () => {
|
||||
signedIn();
|
||||
sessionStorage.setItem("stirling_post_login_path", "/compress");
|
||||
renderAtLogin("");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockNavigate).toHaveBeenCalledWith("/compress", {
|
||||
replace: true,
|
||||
});
|
||||
});
|
||||
// Consumed, so a later sign-in can't reuse a stale path.
|
||||
expect(sessionStorage.getItem("stirling_post_login_path")).toBeNull();
|
||||
});
|
||||
|
||||
// Delegated to the shared isSafePostLoginRedirect, so the backslash form
|
||||
// (browsers normalise "\" to "/") and auth routes are covered too.
|
||||
it.each([
|
||||
|
||||
@@ -7,7 +7,10 @@ import {
|
||||
} from "react-router-dom";
|
||||
import { Button } from "@app/ui/Button";
|
||||
import { isSafePostLoginRedirect } from "@app/auth";
|
||||
import { setPostLoginRedirectPath } from "@app/auth/spring/springAuthClient";
|
||||
import {
|
||||
setPostLoginRedirectPath,
|
||||
consumePostLoginRedirectPath,
|
||||
} from "@app/auth/spring/springAuthClient";
|
||||
import { useAuth } from "@app/auth/UseSession";
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -207,7 +210,8 @@ export default function Login() {
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
if (!session) return;
|
||||
const returnPath = resolveReturnPath();
|
||||
const stashed = consumePostLoginRedirectPath();
|
||||
const returnPath = resolveReturnPath() ?? stashed;
|
||||
if (returnPath) {
|
||||
navigate(returnPath, { replace: true });
|
||||
return;
|
||||
|
||||
@@ -2,11 +2,9 @@ import { lazy } from "react";
|
||||
import type { ReactElement } from "react";
|
||||
import { Route } from "react-router-dom";
|
||||
import { PORTAL_BASENAME } from "@app/routes/portalBasename";
|
||||
import { HAS_PORTAL } from "@app/routes/hasPortal";
|
||||
|
||||
const includePortal =
|
||||
import.meta.env.VITE_INCLUDE_PORTAL === "true" || import.meta.env.DEV;
|
||||
|
||||
const PortalApp = includePortal
|
||||
const PortalApp = HAS_PORTAL
|
||||
? lazy(async () => {
|
||||
const m = await import("@portal/PortalApp");
|
||||
return { default: m.PortalApp };
|
||||
@@ -16,7 +14,7 @@ const PortalApp = includePortal
|
||||
/**
|
||||
* Return leg of the account-link handshake, which Stirling redirects to with the admin's session in the URL fragment.
|
||||
*/
|
||||
const ConnectCallback = includePortal
|
||||
const ConnectCallback = HAS_PORTAL
|
||||
? lazy(async () => {
|
||||
const m = await import("@portal/views/ConnectCallback");
|
||||
return { default: m.default };
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
/** Dev always ships it, so the switch is there to work on. */
|
||||
export const HAS_PORTAL =
|
||||
import.meta.env.VITE_INCLUDE_PORTAL === "true" || import.meta.env.DEV;
|
||||
@@ -1,6 +1,7 @@
|
||||
import { AxiosInstance, AxiosError, InternalAxiosRequestConfig } from "axios";
|
||||
import { withBasePath } from "@app/constants/app";
|
||||
import { stripBasePath, withBasePath } from "@app/constants/app";
|
||||
import { getBrowserId } from "@app/utils/browserIdentifier";
|
||||
import { setPostLoginRedirectPath } from "@app/auth/spring/springAuthClient";
|
||||
|
||||
let isRefreshing = false;
|
||||
let failedQueue: Array<{
|
||||
@@ -93,6 +94,11 @@ async function refreshAuthToken(client: AxiosInstance): Promise<string> {
|
||||
// Redirect to login
|
||||
const loginPath = withBasePath("/login");
|
||||
if (window.location.pathname !== loginPath) {
|
||||
// Router-relative: Login replays this through navigate(), which applies
|
||||
// the basename itself. See the same note in httpErrorHandler.
|
||||
setPostLoginRedirectPath(
|
||||
stripBasePath(window.location.pathname) + window.location.search,
|
||||
);
|
||||
console.log("[API Client] Redirecting to login page...");
|
||||
window.location.href = loginPath;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isSafePostLoginRedirect } from "@app/services/postLoginRedirect";
|
||||
|
||||
// Proprietary override: the core base plus the Spring SSO routes (/oauth2, /saml2).
|
||||
describe("isSafePostLoginRedirect (proprietary override)", () => {
|
||||
it("still accepts ordinary router paths", () => {
|
||||
expect(isSafePostLoginRedirect("/editor")).toBe(true);
|
||||
expect(isSafePostLoginRedirect("/share/abc123?x=1")).toBe(true);
|
||||
expect(isSafePostLoginRedirect("/")).toBe(true);
|
||||
});
|
||||
|
||||
it("inherits the base rejections", () => {
|
||||
expect(isSafePostLoginRedirect("")).toBe(false);
|
||||
expect(isSafePostLoginRedirect(null)).toBe(false);
|
||||
expect(isSafePostLoginRedirect("//evil.example")).toBe(false);
|
||||
expect(isSafePostLoginRedirect("/\\evil")).toBe(false);
|
||||
expect(isSafePostLoginRedirect("/login")).toBe(false);
|
||||
expect(isSafePostLoginRedirect("/auth/callback")).toBe(false);
|
||||
});
|
||||
|
||||
it("also rejects the Spring SSO routes", () => {
|
||||
expect(isSafePostLoginRedirect("/oauth2/authorization/google")).toBe(false);
|
||||
expect(isSafePostLoginRedirect("/saml2/authenticate/x")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import { isSafePostLoginRedirect as isSafeBaseRedirect } from "@core/services/postLoginRedirect";
|
||||
|
||||
export function isSafePostLoginRedirect(path: unknown): path is string {
|
||||
if (!isSafeBaseRedirect(path)) return false;
|
||||
const lowered = path.toLowerCase();
|
||||
return !lowered.startsWith("/oauth2") && !lowered.startsWith("/saml2");
|
||||
}
|
||||
@@ -19,6 +19,8 @@ import OAuthConsent from "@app/routes/OAuthConsent";
|
||||
import ConnectApprove from "@app/routes/ConnectApprove";
|
||||
import ShareLinkPage from "@app/routes/ShareLinkPage";
|
||||
import { getAdminRouteExtensions } from "@app/routes/adminRouteExtensions";
|
||||
import { AppFrame } from "@app/components/layout/AppFrame";
|
||||
import { NoAppChrome } from "@app/components/layout/NoAppChrome";
|
||||
import OnboardingBootstrap from "@app/components/OnboardingBootstrap";
|
||||
import SignupRequiredBootstrap from "@app/components/SignupRequiredBootstrap";
|
||||
import UsageLimitModalHost from "@app/components/UsageLimitModalHost";
|
||||
@@ -96,46 +98,63 @@ export default function App() {
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Admin-only route-set (the portal): its own top-level shell, mounted
|
||||
before the catch-all. */}
|
||||
{getAdminRouteExtensions()}
|
||||
{/* Both apps, under a shared frame so the rail renders once outside them. */}
|
||||
<Route element={<AppFrame />}>
|
||||
{/* The portal: its own top-level shell, before the catch-all. */}
|
||||
{getAdminRouteExtensions()}
|
||||
|
||||
{/* Everything else needs the auth/backend providers. RootGate makes "/"
|
||||
route by role BEFORE any of it mounts, so a user bound for the
|
||||
processor never boots the editor on the way. */}
|
||||
<Route
|
||||
path="*"
|
||||
element={
|
||||
<RootGate>
|
||||
<AppProviders
|
||||
appConfigProviderProps={{ onConfigLoaded: handleConfigLoaded }}
|
||||
>
|
||||
<AppLayout>
|
||||
<NonAuthBootstraps />
|
||||
<ResumePendingConnect />
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/signup" element={<Signup />} />
|
||||
<Route path="/auth/callback" element={<AuthCallback />} />
|
||||
<Route path="/auth/reset" element={<ResetPassword />} />
|
||||
<Route path="/oauth/consent" element={<OAuthConsent />} />
|
||||
{/* Human half of the self-hosted account-link handshake. It
|
||||
lives on this origin because a customer hostname can
|
||||
never be in the provider's redirect allow-list. */}
|
||||
<Route path="/link" element={<ConnectApprove />} />
|
||||
{/* Shared-file links. Team invites are NOT routed here: on
|
||||
SaaS they are accepted in-app via the Supabase team
|
||||
invitation banner, not the Spring password-based
|
||||
/invite/:token page used by the self-hosted build. */}
|
||||
<Route path="/share/:token" element={<ShareLinkPage />} />
|
||||
<Route path="/*" element={<Landing />} />
|
||||
</Routes>
|
||||
<OnboardingTour />
|
||||
</AppLayout>
|
||||
</AppProviders>
|
||||
</RootGate>
|
||||
}
|
||||
/>
|
||||
{/* Everything else needs the auth/backend providers. RootGate routes "/"
|
||||
by role before any of it mounts. */}
|
||||
<Route
|
||||
path="*"
|
||||
element={
|
||||
<RootGate>
|
||||
<AppProviders
|
||||
appConfigProviderProps={{
|
||||
onConfigLoaded: handleConfigLoaded,
|
||||
}}
|
||||
>
|
||||
<AppLayout>
|
||||
<NonAuthBootstraps />
|
||||
<ResumePendingConnect />
|
||||
<Routes>
|
||||
{/* Not the app: no rail over any of these, ever. */}
|
||||
<Route element={<NoAppChrome />}>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/signup" element={<Signup />} />
|
||||
<Route
|
||||
path="/auth/callback"
|
||||
element={<AuthCallback />}
|
||||
/>
|
||||
<Route path="/auth/reset" element={<ResetPassword />} />
|
||||
<Route
|
||||
path="/oauth/consent"
|
||||
element={<OAuthConsent />}
|
||||
/>
|
||||
{/* Human half of the self-hosted account-link handshake.
|
||||
It lives on this origin because a customer hostname can
|
||||
never be in the provider's redirect allow-list. Grouped
|
||||
with the pages above: it is an approval step, not the
|
||||
app. */}
|
||||
<Route path="/link" element={<ConnectApprove />} />
|
||||
{/* Shared-file links. Team invites are NOT routed here:
|
||||
on SaaS they are accepted in-app via the Supabase team
|
||||
invitation banner, not the Spring password-based
|
||||
/invite/:token page used by the self-hosted build. */}
|
||||
<Route
|
||||
path="/share/:token"
|
||||
element={<ShareLinkPage />}
|
||||
/>
|
||||
</Route>
|
||||
<Route path="/*" element={<Landing />} />
|
||||
</Routes>
|
||||
<OnboardingTour />
|
||||
</AppLayout>
|
||||
</AppProviders>
|
||||
</RootGate>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
</Routes>
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { usePortalAccess } from "@app/hooks/usePortalAccess";
|
||||
import { useNavigationActions } from "@app/contexts/NavigationContext";
|
||||
import { PORTAL_BASENAME } from "@app/routes/portalBasename";
|
||||
@@ -13,7 +13,6 @@ import { type NavFooterAppLink } from "@app/components/shared/navFooter/NavFoote
|
||||
export function useOtherAppSwitch(): NavFooterAppLink | null {
|
||||
const portalAccess = usePortalAccess();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { actions } = useNavigationActions();
|
||||
if (!portalAccess) return null;
|
||||
return {
|
||||
@@ -21,7 +20,7 @@ export function useOtherAppSwitch(): NavFooterAppLink | null {
|
||||
onOpen: () =>
|
||||
// Through the guard, so unsaved edits get the same warning as any other navigation.
|
||||
actions.requestNavigation(() => {
|
||||
saveEditorReturnPath(location.pathname + location.search);
|
||||
saveEditorReturnPath();
|
||||
navigate(PORTAL_BASENAME);
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@ import { supabase } from "@app/auth/supabase";
|
||||
import { Button } from "@app/ui/Button";
|
||||
import { withBasePath } from "@app/constants/app";
|
||||
import { readPendingConnect } from "@app/routes/pendingConnect";
|
||||
import { isSafePostLoginRedirect } from "@app/services/postLoginRedirect";
|
||||
import { AuthShell } from "@app/auth/ui/AuthShell";
|
||||
import ErrorMessage from "@app/auth/ui/ErrorMessage";
|
||||
import { Spinner } from "@app/ui/Spinner";
|
||||
@@ -136,18 +137,16 @@ export default function AuthCallback() {
|
||||
// else on the editor.
|
||||
// Explicit `next` first, so a sign-in started for another reason is not
|
||||
// hijacked by a remembered connect request.
|
||||
const explicitNext = url.searchParams.get("next");
|
||||
const explicitNext =
|
||||
url.searchParams.get("next") ?? url.searchParams.get("from");
|
||||
const pendingConnect = readPendingConnect();
|
||||
const destination =
|
||||
explicitNext &&
|
||||
explicitNext.startsWith("/") &&
|
||||
!explicitNext.startsWith("//")
|
||||
? explicitNext
|
||||
: pendingConnect
|
||||
? `/link?request=${encodeURIComponent(pendingConnect)}`
|
||||
: next.startsWith("/") && !next.startsWith("//")
|
||||
? next
|
||||
: await resolveLandingPath();
|
||||
const destination = isSafePostLoginRedirect(explicitNext)
|
||||
? explicitNext
|
||||
: pendingConnect
|
||||
? `/link?request=${encodeURIComponent(pendingConnect)}`
|
||||
: isSafePostLoginRedirect(next)
|
||||
? next
|
||||
: await resolveLandingPath();
|
||||
console.log("[Auth Callback Debug] Redirecting to:", destination);
|
||||
|
||||
setTimeout(() => navigate(destination, { replace: true }), 1500);
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
getBaseUrl,
|
||||
withBasePath,
|
||||
} from "@app/constants/app";
|
||||
import { isSafePostLoginRedirect } from "@app/services/postLoginRedirect";
|
||||
import LinkRoundedIcon from "@mui/icons-material/LinkRounded";
|
||||
|
||||
// Import login components
|
||||
@@ -48,14 +49,14 @@ export default function Login() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Same-origin relative path to return to after login (e.g. the OAuth
|
||||
// consent page). Same sanitization rules as AuthCallback's `next`.
|
||||
// Same-origin router path to return to after login (e.g. the OAuth consent
|
||||
// page, or the editor a 401 bounced the user off). `?next=` is what this app
|
||||
// writes; `?from=` is what the shared core 401 handler writes.
|
||||
const nextPath = useMemo(() => {
|
||||
try {
|
||||
const next = new URL(window.location.href).searchParams.get("next");
|
||||
return next && next.startsWith("/") && !next.startsWith("//")
|
||||
? next
|
||||
: null;
|
||||
const params = new URL(window.location.href).searchParams;
|
||||
const candidate = params.get("next") ?? params.get("from");
|
||||
return isSafePostLoginRedirect(candidate) ? candidate : null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user