diff --git a/.github/workflows/validate-bicep-params.yml b/.github/workflows/validate-bicep-params.yml index a483c8473..31846fcd5 100644 --- a/.github/workflows/validate-bicep-params.yml +++ b/.github/workflows/validate-bicep-params.yml @@ -34,9 +34,16 @@ jobs: - name: Validate infra/ parameters id: validate_infra continue-on-error: true + env: + ACCELERATOR_NAME: ${{ env.accelerator_name }} run: | set +e - python infra/scripts/validate_bicep_params.py --dir infra --strict --no-color --json-output infra_results.json 2>&1 | tee infra_output.txt + RUN_URL="https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" + python infra/scripts/validate_bicep_params.py --dir infra --strict --no-color \ + --json-output infra_results.json \ + --html-output email_body.html \ + --accelerator-name "${ACCELERATOR_NAME}" \ + --run-url "${RUN_URL}" 2>&1 | tee infra_output.txt EXIT_CODE=${PIPESTATUS[0]} set -e echo "## Infra Param Validation" >> "$GITHUB_STEP_SUMMARY" @@ -61,24 +68,25 @@ jobs: name: bicep-validation-results path: | infra_results.json + email_body.html retention-days: 30 - name: Send schedule notification on failure if: github.event_name == 'schedule' && steps.result.outputs.status == 'failure' env: LOGICAPP_URL: ${{ secrets.EMAILNOTIFICATION_LOGICAPP_URL_TA }} - GITHUB_REPOSITORY: ${{ github.repository }} - GITHUB_RUN_ID: ${{ github.run_id }} ACCELERATOR_NAME: ${{ env.accelerator_name }} run: | - RUN_URL="https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" - INFRA_OUTPUT=$(sed 's/&/\&/g; s//\>/g' infra_output.txt) + if [ -f email_body.html ]; then + EMAIL_BODY=$(cat email_body.html) + else + EMAIL_BODY="

Bicep parameter validation failed but no HTML report was generated. Check the workflow run for details.

" + fi jq -n \ --arg name "${ACCELERATOR_NAME}" \ - --arg infra "$INFRA_OUTPUT" \ - --arg url "$RUN_URL" \ - '{subject: ("Bicep Parameter Validation Report - " + $name + " - Issues Detected"), body: ("

Dear Team,

The scheduled Bicep Parameter Validation for " + $name + " has detected parameter mapping errors.

infra/ Results:

" + $infra + "

Run URL: " + $url + "

Please fix the parameter mapping issues at your earliest convenience.

Best regards,
Your Automation Team

")}' \ + --arg body "$EMAIL_BODY" \ + '{subject: ("Bicep Parameter Validation Report - " + $name + " - Issues Detected"), body: $body}' \ | curl -X POST "${LOGICAPP_URL}" \ -H "Content-Type: application/json" \ -d @- || echo "Failed to send notification" @@ -87,18 +95,18 @@ jobs: if: github.event_name == 'schedule' && steps.result.outputs.status == 'success' env: LOGICAPP_URL: ${{ secrets.EMAILNOTIFICATION_LOGICAPP_URL_TA }} - GITHUB_REPOSITORY: ${{ github.repository }} - GITHUB_RUN_ID: ${{ github.run_id }} ACCELERATOR_NAME: ${{ env.accelerator_name }} run: | - RUN_URL="https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" - INFRA_OUTPUT=$(sed 's/&/\&/g; s//\>/g' infra_output.txt) + if [ -f email_body.html ]; then + EMAIL_BODY=$(cat email_body.html) + else + EMAIL_BODY="

Bicep parameter validation passed. Check the workflow run for details.

" + fi jq -n \ --arg name "${ACCELERATOR_NAME}" \ - --arg infra "$INFRA_OUTPUT" \ - --arg url "$RUN_URL" \ - '{subject: ("Bicep Parameter Validation Report - " + $name + " - Passed"), body: ("

Dear Team,

The scheduled Bicep Parameter Validation for " + $name + " has completed successfully. All parameter mappings are valid.

infra/ Results:

" + $infra + "

Run URL: " + $url + "

Best regards,
Your Automation Team

")}' \ + --arg body "$EMAIL_BODY" \ + '{subject: ("Bicep Parameter Validation Report - " + $name + " - Passed"), body: $body}' \ | curl -X POST "${LOGICAPP_URL}" \ -H "Content-Type: application/json" \ -d @- || echo "Failed to send notification" diff --git a/README.md b/README.md index 078ed55d0..3ed312b4e 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,7 @@ To deploy this solution accelerator, ensure you have access to an [Azure subscri Check the [Azure Products by Region](https://azure.microsoft.com/en-us/explore/global-infrastructure/products-by-region/table) page and select a **region** where the following services are available: Azure OpenAI Service, Azure AI Search, and Azure Semantic Search. -Here are some example regions where the services are available: East US, East US2, Japan East, UK South, Sweden Central. +Here are some example regions where the services are available: Australia East, East US2, France Central, Japan East, Norway East, Sweden Central, UK South, West US. Pricing varies per region and usage, so it isn't possible to predict exact costs for your usage. The majority of the Azure resources used in this infrastructure are on usage-based pricing tiers. However, Azure Container Registry has a fixed cost per registry per day. diff --git a/docs/CustomizingAzdParameters.md b/docs/CustomizingAzdParameters.md index c2c052c47..71ce95f29 100644 --- a/docs/CustomizingAzdParameters.md +++ b/docs/CustomizingAzdParameters.md @@ -2,7 +2,7 @@ By default this template will use the environment name as the prefix to prevent naming collisions within Azure. The parameters below show the default values. You only need to run the statements below if you need to change the values. -> To override any of the parameters, run `azd env set ` before running `azd up`. On the first azd command, it will prompt you for the environment name. Be sure to choose 3-20 characters alphanumeric unique name. +> To override any of the parameters, run `azd env set ` before running `azd up`. On the first azd command, it will prompt you for the environment name. Be sure to choose 3-16 characters alphanumeric unique name. ## Parameters @@ -23,7 +23,7 @@ By default this template will use the environment name as the prefix to prevent | `AZURE_ENV_REASONING_MODEL_NAME` | string | `o4-mini` | Specifies the name of the reasoning GPT model to be deployed. | | `AZURE_ENV_REASONING_MODEL_VERSION` | string | `2025-04-16` | Version of the reasoning GPT model to be used for deployment. | | `AZURE_ENV_REASONING_MODEL_CAPACITY` | int | `50` | Sets the reasoning GPT model capacity. | -| `AZURE_ENV_IMAGETAG` | string | `latest_v3` | Docker image tag used for container deployments. | +| `AZURE_ENV_IMAGE_TAG` | string | `latest_v4` | Docker image tag used for container deployments. | | `AZURE_ENV_ENABLE_TELEMETRY` | bool | `true` | Enables telemetry for monitoring and diagnostics. | | `AZURE_EXISTING_AIPROJECT_RESOURCE_ID` | string | `` | Set this if you want to reuse an AI Foundry Project instead of creating a new one. | | `AZURE_ENV_EXISTING_LOG_ANALYTICS_WORKSPACE_RID` | string | Guide to get your [Existing Workspace ID](re-use-log-analytics.md) | Set this if you want to reuse an existing Log Analytics Workspace instead of creating a new one. | diff --git a/docs/azure_app_service_auth_setup.md b/docs/azure_app_service_auth_setup.md index 5b7fc1adc..248ec2fc4 100644 --- a/docs/azure_app_service_auth_setup.md +++ b/docs/azure_app_service_auth_setup.md @@ -26,7 +26,7 @@ This document provides step-by-step instructions to configure Azure App Registra ![Add Provider](./images/azure-app-service-auth-setup/AppAuthIdentityProviderAdd.png) -5. Accept the default values and click on `Add` button to go back to the previous page with the idenity provider added. +5. Accept the default values and click on `Add` button to go back to the previous page with the identity provider added. ![Add Provider](./images/azure-app-service-auth-setup/AppAuthIdentityProviderAdded.png) diff --git a/docs/create_new_app_registration.md b/docs/create_new_app_registration.md index 28edbf452..ce60435cf 100644 --- a/docs/create_new_app_registration.md +++ b/docs/create_new_app_registration.md @@ -20,7 +20,7 @@ ![Redirect URL](images/azure-app-service-auth-setup/AddRedirectURL.png) -6. Click on `+ Add a platform`. +6. Click on `+ Add redirect URI`. ![+ Add platform](images/azure-app-service-auth-setup/AddPlatform.png) diff --git a/docs/images/azure-app-service-auth-setup/AddDetails.png b/docs/images/azure-app-service-auth-setup/AddDetails.png index f36b596f2..f5946c6db 100644 Binary files a/docs/images/azure-app-service-auth-setup/AddDetails.png and b/docs/images/azure-app-service-auth-setup/AddDetails.png differ diff --git a/docs/images/azure-app-service-auth-setup/AddPlatform.png b/docs/images/azure-app-service-auth-setup/AddPlatform.png index 6c74919b4..2424f2a8f 100644 Binary files a/docs/images/azure-app-service-auth-setup/AddPlatform.png and b/docs/images/azure-app-service-auth-setup/AddPlatform.png differ diff --git a/docs/images/azure-app-service-auth-setup/Web.png b/docs/images/azure-app-service-auth-setup/Web.png index 35f846453..d997cbd3a 100644 Binary files a/docs/images/azure-app-service-auth-setup/Web.png and b/docs/images/azure-app-service-auth-setup/Web.png differ diff --git a/infra/main.parameters.json b/infra/main.parameters.json index 313b53e58..7aa975400 100644 --- a/infra/main.parameters.json +++ b/infra/main.parameters.json @@ -48,13 +48,13 @@ "value": "${AZURE_ENV_REASONING_MODEL_CAPACITY}" }, "backendContainerImageTag": { - "value": "${AZURE_ENV_IMAGE_TAG}" + "value": "${AZURE_ENV_IMAGE_TAG=latest_v4}" }, "frontendContainerImageTag": { - "value": "${AZURE_ENV_IMAGE_TAG}" + "value": "${AZURE_ENV_IMAGE_TAG=latest_v4}" }, "MCPContainerImageTag": { - "value": "${AZURE_ENV_IMAGE_TAG}" + "value": "${AZURE_ENV_IMAGE_TAG=latest_v4}" }, "enableTelemetry": { "value": "${AZURE_ENV_ENABLE_TELEMETRY}" diff --git a/infra/scripts/validate_bicep_params.py b/infra/scripts/validate_bicep_params.py index 1e87e6b15..95289548e 100644 --- a/infra/scripts/validate_bicep_params.py +++ b/infra/scripts/validate_bicep_params.py @@ -29,6 +29,7 @@ from __future__ import annotations import argparse +import html import json import re import sys @@ -341,6 +342,241 @@ def print_report(results: list[ValidationResult], *, use_color: bool = True) -> print(f"{c['ERROR']}Parameter mapping issues detected!{c['RESET']}") +# --------------------------------------------------------------------------- +# HTML email report +# --------------------------------------------------------------------------- + +def _html_escape(text: str) -> str: + """Escape HTML special characters.""" + return html.escape(text, quote=True) + + +def generate_html_report( + results: list[ValidationResult], + *, + accelerator_name: str = "", + run_url: str = "", + scan_dir: str = "", +) -> str: + """Build a structured HTML email body from validation results.""" + total_errors = sum( + 1 for r in results for i in r.issues if i.severity == "ERROR" + ) + total_warnings = sum( + 1 for r in results for i in r.issues if i.severity == "WARNING" + ) + has_errors = total_errors > 0 + overall_status = "Issues Detected" if has_errors else "Passed" + status_color = "#D32F2F" if has_errors else "#2E7D32" + status_bg = "#FFEBEE" if has_errors else "#E8F5E9" + status_icon = "❌" if has_errors else "✅" + + parts: list[str] = [] + + # --- Document wrapper (Outlook-compatible, no gradient/border-radius/box-shadow) --- + parts.append( + '' + '' + '' + '
' + '' + ) + + # --- Header banner (solid color, Outlook-safe) --- + parts.append( + f'' + ) + + # --- Summary card --- + parts.append( + f'") + + # --- Per-pair detail sections --- + parts.append('") + + # --- Footer with run URL --- + footer_parts: list[str] = [] + if run_url: + footer_parts.append( + f'View Workflow Run' + ) + if has_errors: + footer_parts.append( + '

' + 'Please fix the parameter mapping issues at your earliest convenience.

' + ) + footer_parts.append( + '

' + 'Best regards,
Your Automation Team

' + ) + parts.append( + f'' + ) + + # --- Close wrapper --- + parts.append("
' + f'

' + f'Bicep Parameter Validation Report

' + f'

' + f'{_html_escape(accelerator_name) if accelerator_name else "Accelerator"}' + f' — Automated Check

' + f'
' + f'' + f'' + f'
' + f'' + f'{status_icon} Overall Status: {overall_status}' + f'
' + f'' + ) + # Accelerator name pill + if accelerator_name: + parts.append( + f'' + ) + # Scan directory pill + if scan_dir: + parts.append( + f'' + ) + # Error count pill + err_pill_color = "#D32F2F" if total_errors > 0 else "#2E7D32" + parts.append( + f'' + ) + # Warning count pill + warn_pill_color = "#F57C00" if total_warnings > 0 else "#2E7D32" + parts.append( + f'' + ) + parts.append("
' + f'Accelerator
' + f'{_html_escape(accelerator_name)}' + f'
' + f'Scan Directory
' + f'{_html_escape(scan_dir)}/' + f'
' + f'Errors
' + f'' + f'{total_errors}
' + f'Warnings
' + f'' + f'{total_warnings}
') + for r in results: + errors = [i for i in r.issues if i.severity == "ERROR"] + warnings = [i for i in r.issues if i.severity == "WARNING"] + + if not r.issues: + badge = ( + 'PASS' + ) + elif errors: + badge = ( + 'FAIL' + ) + else: + badge = ( + 'WARN' + ) + + parts.append( + f'' + f'' + ) + + if r.issues: + # --- Errors section --- + if errors: + parts.append( + '' + '") + + # --- Warnings section --- + if warnings: + parts.append( + '' + '") + else: + parts.append( + '' + ) + + parts.append("
' + f'{badge} ' + f'' + f'{_html_escape(r.pair)}' + f'' + f'{len(errors)} error(s), {len(warnings)} warning(s)' + f'
' + '' + '● Errors
' + '' + '' + '' + '' + ) + for idx, issue in enumerate(errors): + bg = "#ffffff" if idx % 2 == 0 else "#fff5f5" + parts.append( + f'' + f'' + f'' + f'' + ) + parts.append("
ParameterDetails
' + f'{_html_escape(issue.param_name)}{_html_escape(issue.message)}
' + '' + '● Warnings
' + '' + '' + '' + '' + ) + for idx, issue in enumerate(warnings): + bg = "#ffffff" if idx % 2 == 0 else "#fffaf0" + parts.append( + f'' + f'' + f'' + f'' + ) + parts.append("
ParameterDetails
' + f'{_html_escape(issue.param_name)}{_html_escape(issue.message)}
All parameters validated successfully.' + '
") + + parts.append("
' + f'{"".join(footer_parts)}
") + return "".join(parts) + + # --------------------------------------------------------------------------- # CLI # --------------------------------------------------------------------------- @@ -379,6 +615,23 @@ def main() -> int: type=Path, help="Write results as JSON to the given file path.", ) + parser.add_argument( + "--html-output", + type=Path, + help="Write a structured HTML email report to the given file path.", + ) + parser.add_argument( + "--accelerator-name", + type=str, + default="", + help="Accelerator display name for the HTML report header.", + ) + parser.add_argument( + "--run-url", + type=str, + default="", + help="Workflow run URL to include in the HTML report footer.", + ) args = parser.parse_args() results: list[ValidationResult] = [] @@ -415,6 +668,19 @@ def main() -> int: ) print(f"\nJSON report written to {args.json_output}") + # Optional HTML email report + if args.html_output: + scan_dir = str(args.dir) if args.dir else "" + html_report = generate_html_report( + results, + accelerator_name=args.accelerator_name, + run_url=args.run_url, + scan_dir=scan_dir, + ) + args.html_output.parent.mkdir(parents=True, exist_ok=True) + args.html_output.write_text(html_report, encoding="utf-8") + print(f"HTML report written to {args.html_output}") + has_errors = any(r.has_errors for r in results) return 1 if args.strict and has_errors else 0 diff --git a/src/App/src/App.tsx b/src/App/src/App.tsx index a4fc17c6d..59a2b0b4e 100644 --- a/src/App/src/App.tsx +++ b/src/App/src/App.tsx @@ -1,11 +1,19 @@ -import React from 'react'; +import React, { useEffect } from 'react'; import './App.css'; import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'; import { HomePage, PlanPage } from './pages'; import { useWebSocket } from './hooks/useWebSocket'; +import { useAppDispatch } from './store/hooks'; +import { hydrateCurrentUser } from './store/slices/appSlice'; +import { getUserInfoGlobal } from './api/config'; function App() { useWebSocket(); + const dispatch = useAppDispatch(); + + useEffect(() => { + dispatch(hydrateCurrentUser(getUserInfoGlobal())); + }, [dispatch]); return ( diff --git a/src/App/src/commonComponents/components/Panels/PanelFooter.tsx b/src/App/src/commonComponents/components/Panels/PanelFooter.tsx index d0ae9619f..ed0aca9c3 100644 --- a/src/App/src/commonComponents/components/Panels/PanelFooter.tsx +++ b/src/App/src/commonComponents/components/Panels/PanelFooter.tsx @@ -4,7 +4,7 @@ const PanelFooter: React.FC<{ children: React.ReactNode }> = ({ children }) => { return (
diff --git a/src/App/src/commonComponents/components/Panels/PanelLeft.tsx b/src/App/src/commonComponents/components/Panels/PanelLeft.tsx index 19c124eae..1baceafa7 100644 --- a/src/App/src/commonComponents/components/Panels/PanelLeft.tsx +++ b/src/App/src/commonComponents/components/Panels/PanelLeft.tsx @@ -98,7 +98,7 @@ const PanelLeft: React.FC = ({ {content}
- {footer &&
{footer}
} + {footer &&
{footer}
} {panelResize && (
{ + if (!name) return 'U'; + const cleanName = name.replace(/\s*\([^)]*\)/g, '').trim(); + if (!cleanName) return 'U'; + const parts = cleanName.split(/\s+/).filter(Boolean); + if (parts.length >= 2) { + return (parts[0][0] + parts[1][0]).toUpperCase(); + } + return cleanName.charAt(0).toUpperCase(); +}; + +interface LoginButtonProps { + showName?: boolean; +} + +const LoginButton: React.FC = ({ showName = false }) => { + const styles = useStyles(); + const userName = useAppSelector(state => state.app.userName); + const userId = useAppSelector(state => state.app.userId); + const userEmail = useAppSelector(state => state.app.userEmail); + const isAuthenticated = Boolean(userId && userId !== 'anonymous'); + + const logout = useCallback(() => { + const logoutUrl = '/.auth/logout?post_logout_redirect_uri=' + encodeURIComponent('/.auth/login/aad'); + window.location.href = logoutUrl; + }, []); + + const displayName = isAuthenticated ? userName || userId || 'User' : 'Guest'; + + if (!isAuthenticated) { + return ( +
+ + {showName && Guest} +
+ ); + } + + return ( + + + + } + > + {showName ? {displayName} : undefined} + + + + + + } disabled style={{ cursor: 'default' }}> +
+
{displayName}
+ {userEmail &&
{userEmail}
} +
+
+ } + onClick={logout} + > + Sign out + +
+
+
+ ); +}; + +export default LoginButton; diff --git a/src/App/src/components/content/PlanPanelLeft.tsx b/src/App/src/components/content/PlanPanelLeft.tsx index d9bfe023c..f1fb34b49 100644 --- a/src/App/src/components/content/PlanPanelLeft.tsx +++ b/src/App/src/components/content/PlanPanelLeft.tsx @@ -1,6 +1,7 @@ import React from "react"; import PanelLeft from "@/commonComponents/components/Panels/PanelLeft"; import PanelLeftToolbar from "@/commonComponents/components/Panels/PanelLeftToolbar"; +import PanelFooter from "@/commonComponents/components/Panels/PanelFooter"; import { Body1Strong, Toast, @@ -16,14 +17,12 @@ import { import TaskList from "./TaskList"; import { useCallback, useEffect, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; -import { Plan, PlanPanelLefProps, Task, UserInfo } from "@/models"; +import { Plan, PlanPanelLefProps, Task } from "@/models"; import { apiService } from "@/api"; import { TaskService } from "@/store"; import ContosoLogo from "../../commonComponents/imports/ContosoLogo"; import "../../styles/PlanPanelLeft.css"; -import PanelFooter from "@/commonComponents/components/Panels/PanelFooter"; -import PanelUserCard from "../../commonComponents/components/Panels/UserCard"; -import { getUserInfoGlobal } from "@/api/config"; +import LoginButton from "../auth/LoginButton"; import TeamSelector from "../common/TeamSelector"; import { TeamConfig } from "../../models/Team"; import TeamSelected from "../common/TeamSelected"; @@ -47,9 +46,6 @@ const PlanPanelLeft: React.FC = ({ const [plans, setPlans] = useState(null); const [plansLoading, setPlansLoading] = useState(false); const [plansError, setPlansError] = useState(null); - const [userInfo, setUserInfo] = useState( - getUserInfoGlobal() - ); // Use parent's selected team if provided, otherwise use local state const [localSelectedTeam, setLocalSelectedTeam] = useState(null); @@ -86,15 +82,14 @@ const PlanPanelLeft: React.FC = ({ useEffect(() => { loadPlansData(); - setUserInfo(getUserInfoGlobal()); - }, [loadPlansData, setUserInfo]); + }, [loadPlansData]); useEffect(() => { if (reloadTasks) { loadPlansData(true); // Force refresh when reloadTasks is true } - }, [loadPlansData, setUserInfo, reloadTasks]); + }, [loadPlansData, reloadTasks]); useEffect(() => { if (plans) { const { completed } = @@ -250,12 +245,7 @@ const PlanPanelLeft: React.FC = ({
- {/* User Card */} - +
diff --git a/src/App/src/store/slices/appSlice.ts b/src/App/src/store/slices/appSlice.ts index 6fdebc915..7c5b4a352 100644 --- a/src/App/src/store/slices/appSlice.ts +++ b/src/App/src/store/slices/appSlice.ts @@ -1,8 +1,9 @@ /** - * App Slice — global application state: config, theme, WebSocket connection. + * App Slice — global application state: config, theme, WebSocket connection, auth. */ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import type { RootState } from '../store'; +import { UserInfo } from '../../models'; export interface AppState { /** Has the runtime config been loaded from /config? */ @@ -11,12 +12,21 @@ export interface AppState { isDarkMode: boolean; /** Is the global WebSocket connected? */ wsConnected: boolean; + /** Current user ID from EasyAuth */ + userId: string; + /** Current user display name */ + userName: string; + /** Current user email */ + userEmail: string; } const initialState: AppState = { configLoaded: false, isDarkMode: window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false, wsConnected: false, + userId: '', + userName: '', + userEmail: '', }; const appSlice = createSlice({ @@ -32,6 +42,30 @@ const appSlice = createSlice({ setWsConnected(state, action: PayloadAction) { state.wsConnected = action.payload; }, + hydrateCurrentUser(state, action: PayloadAction) { + const userInfo = action.payload; + if (!userInfo || !userInfo.user_id) { + state.userId = 'anonymous'; + state.userName = ''; + state.userEmail = ''; + return; + } + state.userId = userInfo.user_id || 'anonymous'; + state.userName = userInfo.user_first_last_name || ''; + // Extract email from claims + const userClaims = userInfo.user_claims || []; + let emailVal = userInfo.user_email || ''; + for (const claim of userClaims) { + if (claim.typ === 'preferred_username' || + claim.typ === 'email' || + claim.typ === 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress' || + claim.typ === 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn') { + emailVal = claim.val; + break; + } + } + state.userEmail = emailVal; + }, }, }); @@ -39,6 +73,7 @@ export const { setConfigLoaded, setIsDarkMode, setWsConnected, + hydrateCurrentUser, } = appSlice.actions; /* ── Granular Selectors ───────────────────────────────────────── */ diff --git a/src/App/src/styles/PlanPanelLeft.css b/src/App/src/styles/PlanPanelLeft.css index ad8d3ea6f..c621f2d25 100644 --- a/src/App/src/styles/PlanPanelLeft.css +++ b/src/App/src/styles/PlanPanelLeft.css @@ -56,6 +56,8 @@ color: #2F2F4A; */ flex-direction: column; gap: 12px; width: 100%; + align-items: flex-start; + padding-left: 8px; } /* TASKLIST */