Prettier 2: Electric Boogaloo (#6113)

# Description of Changes
When I added Prettier formatting in #6052, my aim was to use just the
default settings in Prettier. Turns out, Prettier looks _really hard_
for any config files if it's not explicitly given one, which means that
if a developer has some sort of Prettier config file lying around on
their system, Prettier might find it and use it. Also, Prettier changes
its defaults based on stuff in `.editorconfig` without any good way of
disabling that behaviour explicitly in its config file.

To solve both of these issues, I've introduced a `.prettierrc` file
which sets Prettier's defaults explicitly, and then reformatted all our
code _again_ in Prettier's actual default settings. This should achieve
the aim of #6052 and remove the possibility for it breaking on different
dev computers.
This commit is contained in:
James Brunton
2026-04-17 09:50:16 +00:00
committed by GitHub
parent de8c483054
commit 8ab060a4be
1207 changed files with 52646 additions and 15352 deletions
@@ -2,7 +2,10 @@ import React from "react";
import { Button, Group, ActionIcon } from "@mantine/core";
import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
import { useTranslation } from "react-i18next";
import { ButtonDefinition, type FlowState } from "@app/components/onboarding/onboardingFlowConfig";
import {
ButtonDefinition,
type FlowState,
} from "@app/components/onboarding/onboardingFlowConfig";
import type { LicenseNotice } from "@app/types/types";
import type { ButtonAction } from "@app/components/onboarding/onboardingFlowConfig";
@@ -16,10 +19,19 @@ interface SlideButtonsProps {
onAction: (action: ButtonAction) => void;
}
export function SlideButtons({ slideDefinition, licenseNotice, flowState, onAction }: SlideButtonsProps) {
export function SlideButtons({
slideDefinition,
licenseNotice,
flowState,
onAction,
}: SlideButtonsProps) {
const { t } = useTranslation();
const leftButtons = slideDefinition.buttons.filter((btn) => btn.group === "left");
const rightButtons = slideDefinition.buttons.filter((btn) => btn.group === "right");
const leftButtons = slideDefinition.buttons.filter(
(btn) => btn.group === "left",
);
const rightButtons = slideDefinition.buttons.filter(
(btn) => btn.group === "right",
);
const buttonStyles = (variant: ButtonDefinition["variant"]) =>
variant === "primary"
@@ -76,7 +88,9 @@ export function SlideButtons({ slideDefinition, licenseNotice, flowState, onActi
},
}}
>
{button.icon === "chevron-left" && <ChevronLeftIcon fontSize="small" />}
{button.icon === "chevron-left" && (
<ChevronLeftIcon fontSize="small" />
)}
</ActionIcon>
);
}
@@ -85,7 +99,12 @@ export function SlideButtons({ slideDefinition, licenseNotice, flowState, onActi
const label = resolveButtonLabel(button);
return (
<Button key={button.key} onClick={() => onAction(button.action)} disabled={disabled} styles={buttonStyles(variant)}>
<Button
key={button.key}
onClick={() => onAction(button.action)}
disabled={disabled}
styles={buttonStyles(variant)}
>
{label}
</Button>
);
@@ -6,11 +6,21 @@ import { isAuthRoute } from "@app/constants/routes";
import { dispatchTourState } from "@app/constants/events";
import { useOnboardingOrchestrator } from "@app/components/onboarding/orchestrator/useOnboardingOrchestrator";
import { useBypassOnboarding } from "@app/components/onboarding/useBypassOnboarding";
import OnboardingTour, { type AdvanceArgs, type CloseArgs } from "@app/components/onboarding/OnboardingTour";
import OnboardingTour, {
type AdvanceArgs,
type CloseArgs,
} from "@app/components/onboarding/OnboardingTour";
import OnboardingModalSlide from "@app/components/onboarding/OnboardingModalSlide";
import { useServerLicenseRequest, useTourRequest } from "@app/components/onboarding/useOnboardingEffects";
import {
useServerLicenseRequest,
useTourRequest,
} from "@app/components/onboarding/useOnboardingEffects";
import { useOnboardingDownload } from "@app/components/onboarding/useOnboardingDownload";
import { SLIDE_DEFINITIONS, type SlideId, type ButtonAction } from "@app/components/onboarding/onboardingFlowConfig";
import {
SLIDE_DEFINITIONS,
type SlideId,
type ButtonAction,
} from "@app/components/onboarding/onboardingFlowConfig";
import ToolPanelModePrompt from "@app/components/tools/ToolPanelModePrompt";
import { useTourOrchestration } from "@app/contexts/TourOrchestrationContext";
import { useAdminTourOrchestration } from "@app/contexts/AdminTourOrchestrationContext";
@@ -36,9 +46,18 @@ export default function Onboarding() {
const onAuthRoute = isAuthRoute(location.pathname);
const { currentStep, isActive, isLoading, runtimeState, activeFlow } = state;
const { osInfo, osOptions, setSelectedDownloadUrl, handleDownloadSelected } = useOnboardingDownload();
const { showLicenseSlide, licenseNotice: externalLicenseNotice, closeLicenseSlide } = useServerLicenseRequest();
const { tourRequested: externalTourRequested, requestedTourType, clearTourRequest } = useTourRequest();
const { osInfo, osOptions, setSelectedDownloadUrl, handleDownloadSelected } =
useOnboardingDownload();
const {
showLicenseSlide,
licenseNotice: externalLicenseNotice,
closeLicenseSlide,
} = useServerLicenseRequest();
const {
tourRequested: externalTourRequested,
requestedTourType,
clearTourRequest,
} = useTourRequest();
const { config, refetch: refetchConfig } = useAppConfig();
const [analyticsError, setAnalyticsError] = useState<string | null>(null);
const [analyticsLoading, setAnalyticsLoading] = useState(false);
@@ -75,10 +94,20 @@ export default function Onboarding() {
// Check if we should show analytics modal before onboarding
useEffect(() => {
if (!isLoading && !analyticsModalDismissed && serverExperience.effectiveIsAdmin && config?.enableAnalytics == null) {
if (
!isLoading &&
!analyticsModalDismissed &&
serverExperience.effectiveIsAdmin &&
config?.enableAnalytics == null
) {
setShowAnalyticsModal(true);
}
}, [isLoading, analyticsModalDismissed, serverExperience.effectiveIsAdmin, config?.enableAnalytics]);
}, [
isLoading,
analyticsModalDismissed,
serverExperience.effectiveIsAdmin,
config?.enableAnalytics,
]);
const handleAnalyticsChoice = useCallback(
async (enableAnalytics: boolean) => {
@@ -90,12 +119,17 @@ export default function Onboarding() {
formData.append("enabled", enableAnalytics.toString());
try {
await apiClient.post("/api/v1/settings/update-enable-analytics", formData);
await apiClient.post(
"/api/v1/settings/update-enable-analytics",
formData,
);
await refetchConfig();
setShowAnalyticsModal(false);
setAnalyticsModalDismissed(true);
} catch (error) {
setAnalyticsError(error instanceof Error ? error.message : "Unknown error");
setAnalyticsError(
error instanceof Error ? error.message : "Unknown error",
);
} finally {
setAnalyticsLoading(false);
}
@@ -137,7 +171,11 @@ export default function Onboarding() {
setIsTourOpen(true);
break;
case "launch-auto": {
const tourType = serverExperience.effectiveIsAdmin || runtimeState.selectedRole === "admin" ? "admin" : "whatsnew";
const tourType =
serverExperience.effectiveIsAdmin ||
runtimeState.selectedRole === "admin"
? "admin"
: "whatsnew";
actions.updateRuntimeState({ tourType });
setIsTourOpen(true);
break;
@@ -170,7 +208,10 @@ export default function Onboarding() {
],
);
const isRTL = typeof document !== "undefined" ? document.documentElement.dir === "rtl" : false;
const isRTL =
typeof document !== "undefined"
? document.documentElement.dir === "rtl"
: false;
const [isTourOpen, setIsTourOpen] = useState(false);
useEffect(() => dispatchTourState(isTourOpen), [isTourOpen]);
@@ -241,7 +282,12 @@ export default function Onboarding() {
default:
return Object.values(userStepsConfig);
}
}, [adminStepsConfig, runtimeState.tourType, userStepsConfig, whatsNewStepsConfig]);
}, [
adminStepsConfig,
runtimeState.tourType,
userStepsConfig,
whatsNewStepsConfig,
]);
useEffect(() => {
if (externalTourRequested) {
@@ -290,7 +336,12 @@ export default function Onboarding() {
const handleAdvanceTour = useCallback(
(args: AdvanceArgs) => {
const { setCurrentStep, currentStep: tourCurrentStep, steps, setIsOpen } = args;
const {
setCurrentStep,
currentStep: tourCurrentStep,
steps,
setIsOpen,
} = args;
if (steps && tourCurrentStep === steps.length - 1) {
setIsOpen(false);
finishTour();
@@ -310,7 +361,11 @@ export default function Onboarding() {
);
const currentSlideDefinition = useMemo(() => {
if (!currentStep || currentStep.type !== "modal-slide" || !currentStep.slideId) {
if (
!currentStep ||
currentStep.type !== "modal-slide" ||
!currentStep.slideId
) {
return null;
}
return SLIDE_DEFINITIONS[currentStep.slideId as SlideId];
@@ -356,7 +411,9 @@ export default function Onboarding() {
const currentModalSlideIndex = useMemo(() => {
if (!currentStep || currentStep.type !== "modal-slide") return 0;
const modalSlides = activeFlow.filter((step) => step.type === "modal-slide");
const modalSlides = activeFlow.filter(
(step) => step.type === "modal-slide",
);
return modalSlides.findIndex((step) => step.id === currentStep.id);
}, [activeFlow, currentStep]);
@@ -464,9 +521,12 @@ export default function Onboarding() {
// Remove back button for external license notice
const slideDefinition = {
...baseSlideDefinition,
buttons: baseSlideDefinition.buttons.filter((btn) => btn.key !== "license-back"),
buttons: baseSlideDefinition.buttons.filter(
(btn) => btn.key !== "license-back",
),
};
const effectiveLicenseNotice = externalLicenseNotice || runtimeState.licenseNotice;
const effectiveLicenseNotice =
externalLicenseNotice || runtimeState.licenseNotice;
const slideContent = slideDefinition.createSlide({
osLabel: "",
osUrl: "",
@@ -482,7 +542,10 @@ export default function Onboarding() {
<OnboardingModalSlide
slideDefinition={slideDefinition}
slideContent={slideContent}
runtimeState={{ ...runtimeState, licenseNotice: effectiveLicenseNotice }}
runtimeState={{
...runtimeState,
licenseNotice: effectiveLicenseNotice,
}}
modalSlideCount={1}
currentModalSlideIndex={0}
onSkip={closeLicenseSlide}
@@ -524,7 +587,9 @@ export default function Onboarding() {
// Render the current onboarding step
switch (currentStep.type) {
case "tool-prompt":
return <ToolPanelModePrompt forceOpen={true} onComplete={actions.complete} />;
return (
<ToolPanelModePrompt forceOpen={true} onComplete={actions.complete} />
);
case "modal-slide":
if (!currentSlideDefinition || !currentSlideContent) return null;
@@ -10,7 +10,10 @@ import { Modal, Stack, ActionIcon } from "@mantine/core";
import DiamondOutlinedIcon from "@mui/icons-material/DiamondOutlined";
import CloseIcon from "@mui/icons-material/Close";
import type { SlideDefinition, ButtonAction } from "@app/components/onboarding/onboardingFlowConfig";
import type {
SlideDefinition,
ButtonAction,
} from "@app/components/onboarding/onboardingFlowConfig";
import type { OnboardingRuntimeState } from "@app/components/onboarding/orchestrator/onboardingConfig";
import type { SlideConfig } from "@app/types/types";
import AnimatedSlideBackground from "@app/components/onboarding/slides/AnimatedSlideBackground";
@@ -47,7 +50,11 @@ export default function OnboardingModalSlide({
return (
<div className={styles.heroIconsContainer}>
<div className={styles.iconWrapper}>
<img src={`${BASE_PATH}/modern-logo/logo512.png`} alt="Stirling icon" className={styles.downloadIcon} />
<img
src={`${BASE_PATH}/modern-logo/logo512.png`}
alt="Stirling icon"
className={styles.downloadIcon}
/>
</div>
</div>
);
@@ -56,20 +63,45 @@ export default function OnboardingModalSlide({
return (
<div className={styles.heroLogoCircle}>
{slideDefinition.hero.type === "rocket" && (
<LocalIcon icon="rocket-launch" width={64} height={64} className={styles.heroIcon} />
<LocalIcon
icon="rocket-launch"
width={64}
height={64}
className={styles.heroIcon}
/>
)}
{slideDefinition.hero.type === "shield" && (
<LocalIcon icon="verified-user-outline" width={64} height={64} className={styles.heroIcon} />
<LocalIcon
icon="verified-user-outline"
width={64}
height={64}
className={styles.heroIcon}
/>
)}
{slideDefinition.hero.type === "lock" && (
<LocalIcon icon="lock-outline" width={64} height={64} className={styles.heroIcon} />
<LocalIcon
icon="lock-outline"
width={64}
height={64}
className={styles.heroIcon}
/>
)}
{slideDefinition.hero.type === "analytics" && (
<LocalIcon icon="analytics" width={64} height={64} className={styles.heroIcon} />
<LocalIcon
icon="analytics"
width={64}
height={64}
className={styles.heroIcon}
/>
)}
{slideDefinition.hero.type === "diamond" && (
<DiamondOutlinedIcon sx={{ fontSize: 64, color: "#000000" }} />
)}
{slideDefinition.hero.type === "diamond" && <DiamondOutlinedIcon sx={{ fontSize: 64, color: "#000000" }} />}
{slideDefinition.hero.type === "logo" && (
<img src={`${BASE_PATH}/branding/StirlingPDFLogoNoTextLightHC.svg`} alt="Stirling logo" />
<img
src={`${BASE_PATH}/branding/StirlingPDFLogoNoTextLightHC.svg`}
alt="Stirling logo"
/>
)}
</div>
);
@@ -88,7 +120,12 @@ export default function OnboardingModalSlide({
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
styles={{
body: { padding: 0, maxHeight: "90vh", overflow: "hidden" },
content: { overflow: "hidden", border: "none", background: "var(--bg-surface)", maxHeight: "90vh" },
content: {
overflow: "hidden",
border: "none",
background: "var(--bg-surface)",
maxHeight: "90vh",
},
}}
>
<Stack gap={0} className={styles.modalContent}>
@@ -129,20 +166,34 @@ export default function OnboardingModalSlide({
</div>
</div>
<div className={styles.modalBody} style={{ overflowY: "auto", maxHeight: "calc(90vh - 220px)" }}>
<div
className={styles.modalBody}
style={{ overflowY: "auto", maxHeight: "calc(90vh - 220px)" }}
>
<Stack gap={16}>
<div key={`title-${slideContent.key}`} className={`${styles.title} ${styles.titleText}`}>
<div
key={`title-${slideContent.key}`}
className={`${styles.title} ${styles.titleText}`}
>
{slideContent.title}
</div>
<div className={styles.bodyText}>
<div key={`body-${slideContent.key}`} className={`${styles.bodyCopy} ${styles.bodyCopyInner}`}>
<div
key={`body-${slideContent.key}`}
className={`${styles.bodyCopy} ${styles.bodyCopyInner}`}
>
{slideContent.body}
</div>
<style>{`div strong{color: var(--onboarding-title); font-weight: 600;}`}</style>
</div>
{modalSlideCount > 1 && <OnboardingStepper totalSteps={modalSlideCount} activeStep={currentModalSlideIndex} />}
{modalSlideCount > 1 && (
<OnboardingStepper
totalSteps={modalSlideCount}
activeStep={currentModalSlideIndex}
/>
)}
<div className={styles.buttonContainer}>
<SlideButtons
@@ -10,7 +10,11 @@ interface OnboardingStepperProps {
* Renders a progress indicator where the active step is a pill and others are dots.
* Colors come from theme.css variables.
*/
export function OnboardingStepper({ totalSteps, activeStep, className }: OnboardingStepperProps) {
export function OnboardingStepper({
totalSteps,
activeStep,
className,
}: OnboardingStepperProps) {
const items = Array.from({ length: totalSteps }, (_, index) => index);
return (
@@ -26,7 +30,9 @@ export function OnboardingStepper({ totalSteps, activeStep, className }: Onboard
{items.map((index) => {
const isActive = index === activeStep;
const baseStyles: React.CSSProperties = {
background: isActive ? "var(--onboarding-step-active)" : "var(--onboarding-step-inactive)",
background: isActive
? "var(--onboarding-step-active)"
: "var(--onboarding-step-inactive)",
};
return (
@@ -57,7 +57,15 @@ interface OnboardingTourProps {
onClose: (args: CloseArgs) => void;
}
export default function OnboardingTour({ tourSteps, tourType, isRTL, t, isOpen, onAdvance, onClose }: OnboardingTourProps) {
export default function OnboardingTour({
tourSteps,
tourType,
isRTL,
t,
isOpen,
onAdvance,
onClose,
}: OnboardingTourProps) {
if (!isOpen) return null;
return (
@@ -111,15 +119,31 @@ export default function OnboardingTour({ tourSteps, tourType, isRTL, t, isOpen,
disableInteraction={true}
disableDotsNavigation={false}
prevButton={() => null}
nextButton={({ currentStep: tourCurrentStep, stepsLength, setCurrentStep, setIsOpen }) => {
nextButton={({
currentStep: tourCurrentStep,
stepsLength,
setCurrentStep,
setIsOpen,
}) => {
const isLast = tourCurrentStep === stepsLength - 1;
const ArrowIcon = isRTL ? ArrowBackIcon : ArrowForwardIcon;
return (
<ActionIcon
onClick={() => onAdvance({ setCurrentStep, currentStep: tourCurrentStep, steps: tourSteps, setIsOpen })}
onClick={() =>
onAdvance({
setCurrentStep,
currentStep: tourCurrentStep,
steps: tourSteps,
setIsOpen,
})
}
variant="subtle"
size="lg"
aria-label={isLast ? t("onboarding.finish", "Finish") : t("onboarding.next", "Next")}
aria-label={
isLast
? t("onboarding.finish", "Finish")
: t("onboarding.next", "Next")
}
>
{isLast ? <CheckIcon /> : <ArrowIcon />}
</ActionIcon>
@@ -127,10 +151,17 @@ export default function OnboardingTour({ tourSteps, tourType, isRTL, t, isOpen,
}}
components={{
Close: ({ onClick }) => (
<CloseButton onClick={onClick} size="md" style={{ position: "absolute", top: "8px", right: "8px" }} />
<CloseButton
onClick={onClick}
size="md"
style={{ position: "absolute", top: "8px", right: "8px" }}
/>
),
Content: ({ content }: { content: string }) => (
<div style={{ paddingRight: "16px" }} dangerouslySetInnerHTML={{ __html: content }} />
<div
style={{ paddingRight: "16px" }}
dangerouslySetInnerHTML={{ __html: content }}
/>
),
}}
>
@@ -1,6 +1,9 @@
import type { StepType } from "@reactour/tour";
import type { TFunction } from "i18next";
import { addGlowToElements, removeAllGlows } from "@app/components/onboarding/tourGlow";
import {
addGlowToElements,
removeAllGlows,
} from "@app/components/onboarding/tourGlow";
export enum AdminTourStep {
WELCOME,
@@ -26,8 +29,16 @@ interface CreateAdminStepsConfigArgs {
actions: AdminStepActions;
}
export function createAdminStepsConfig({ t, actions }: CreateAdminStepsConfigArgs): Record<AdminTourStep, StepType> {
const { saveAdminState, openConfigModal, navigateToSection, scrollNavToSection } = actions;
export function createAdminStepsConfig({
t,
actions,
}: CreateAdminStepsConfigArgs): Record<AdminTourStep, StepType> {
const {
saveAdminState,
openConfigModal,
navigateToSection,
scrollNavToSection,
} = actions;
return {
[AdminTourStep.WELCOME]: {
@@ -120,7 +131,10 @@ export function createAdminStepsConfig({ t, actions }: CreateAdminStepsConfigArg
},
[AdminTourStep.DATABASE_SECTION]: {
selector: '[data-tour="admin-adminDatabase-nav"]',
highlightedSelectors: ['[data-tour="admin-adminDatabase-nav"]', '[data-tour="settings-content-area"]'],
highlightedSelectors: [
'[data-tour="admin-adminDatabase-nav"]',
'[data-tour="settings-content-area"]',
],
content: t(
"adminOnboarding.databaseSection",
"For advanced production environments, we have settings to allow <strong>external database hookups</strong> so you can integrate with your existing infrastructure.",
@@ -131,13 +145,19 @@ export function createAdminStepsConfig({ t, actions }: CreateAdminStepsConfigArg
removeAllGlows();
navigateToSection("adminDatabase");
setTimeout(() => {
addGlowToElements(['[data-tour="admin-adminDatabase-nav"]', '[data-tour="settings-content-area"]']);
addGlowToElements([
'[data-tour="admin-adminDatabase-nav"]',
'[data-tour="settings-content-area"]',
]);
}, 100);
},
},
[AdminTourStep.CONNECTIONS_SECTION]: {
selector: '[data-tour="admin-adminConnections-nav"]',
highlightedSelectors: ['[data-tour="admin-adminConnections-nav"]', '[data-tour="settings-content-area"]'],
highlightedSelectors: [
'[data-tour="admin-adminConnections-nav"]',
'[data-tour="settings-content-area"]',
],
content: t(
"adminOnboarding.connectionsSection",
"The <strong>Connections</strong> section supports various login methods including custom SSO and SAML providers like Google and GitHub, plus email integrations for notifications and communications.",
@@ -148,7 +168,10 @@ export function createAdminStepsConfig({ t, actions }: CreateAdminStepsConfigArg
removeAllGlows();
navigateToSection("adminConnections");
setTimeout(() => {
addGlowToElements(['[data-tour="admin-adminConnections-nav"]', '[data-tour="settings-content-area"]']);
addGlowToElements([
'[data-tour="admin-adminConnections-nav"]',
'[data-tour="settings-content-area"]',
]);
}, 100);
},
actionAfter: async () => {
@@ -20,7 +20,14 @@ export type SlideId =
| "analytics-choice"
| "mfa-setup";
export type HeroType = "rocket" | "dual-icon" | "shield" | "diamond" | "logo" | "lock" | "analytics";
export type HeroType =
| "rocket"
| "dual-icon"
| "shield"
| "diamond"
| "logo"
| "lock"
| "analytics";
export type ButtonAction =
| "next"
@@ -91,7 +98,11 @@ export interface SlideDefinition {
export const SLIDE_DEFINITIONS: Record<SlideId, SlideDefinition> = {
"first-login": {
id: "first-login",
createSlide: ({ firstLoginUsername, onPasswordChanged, usingDefaultCredentials }) =>
createSlide: ({
firstLoginUsername,
onPasswordChanged,
usingDefaultCredentials,
}) =>
FirstLoginSlide({
username: firstLoginUsername || "",
onPasswordChanged: onPasswordChanged || (() => {}),
@@ -148,7 +159,8 @@ export const SLIDE_DEFINITIONS: Record<SlideId, SlideDefinition> = {
},
"security-check": {
id: "security-check",
createSlide: ({ selectedRole, onRoleSelect }) => SecurityCheckSlide({ selectedRole, onRoleSelect }),
createSlide: ({ selectedRole, onRoleSelect }) =>
SecurityCheckSlide({ selectedRole, onRoleSelect }),
hero: { type: "shield" },
buttons: [
{
@@ -172,7 +184,8 @@ export const SLIDE_DEFINITIONS: Record<SlideId, SlideDefinition> = {
},
"admin-overview": {
id: "admin-overview",
createSlide: ({ licenseNotice, loginEnabled }) => PlanOverviewSlide({ isAdmin: true, licenseNotice, loginEnabled }),
createSlide: ({ licenseNotice, loginEnabled }) =>
PlanOverviewSlide({ isAdmin: true, licenseNotice, loginEnabled }),
hero: { type: "diamond" },
buttons: [
{
@@ -262,7 +275,8 @@ export const SLIDE_DEFINITIONS: Record<SlideId, SlideDefinition> = {
},
"analytics-choice": {
id: "analytics-choice",
createSlide: ({ analyticsError }) => AnalyticsChoiceSlide({ analyticsError }),
createSlide: ({ analyticsError }) =>
AnalyticsChoiceSlide({ analyticsError }),
hero: { type: "analytics" },
buttons: [
{
@@ -285,7 +299,8 @@ export const SLIDE_DEFINITIONS: Record<SlideId, SlideDefinition> = {
},
"mfa-setup": {
id: "mfa-setup",
createSlide: ({ onMfaSetupComplete = () => {} }: SlideFactoryParams) => MFASetupSlide({ onMfaSetupComplete }),
createSlide: ({ onMfaSetupComplete = () => {} }: SlideFactoryParams) =>
MFASetupSlide({ onMfaSetupComplete }),
hero: { type: "lock" },
buttons: [], // Form has its own submit button
},
@@ -115,13 +115,15 @@ export const ONBOARDING_STEPS: OnboardingStep[] = [
id: "tour-overview",
type: "modal-slide",
slideId: "tour-overview",
condition: (ctx) => !ctx.effectiveIsAdmin && ctx.tourType !== "admin" && !ctx.isDesktopApp,
condition: (ctx) =>
!ctx.effectiveIsAdmin && ctx.tourType !== "admin" && !ctx.isDesktopApp,
},
{
id: "server-license",
type: "modal-slide",
slideId: "server-license",
condition: (ctx) => ctx.effectiveIsAdmin && ctx.licenseNotice.requiresLicense,
condition: (ctx) =>
ctx.effectiveIsAdmin && ctx.licenseNotice.requiresLicense,
},
{
id: "mfa-setup",
@@ -16,7 +16,10 @@ export function markOnboardingCompleted(): void {
try {
localStorage.setItem(ONBOARDING_COMPLETED_KEY, "true");
} catch (error) {
console.error("[onboardingStorage] Error marking onboarding as completed:", error);
console.error(
"[onboardingStorage] Error marking onboarding as completed:",
error,
);
}
}
@@ -25,7 +28,10 @@ export function resetOnboardingProgress(): void {
try {
localStorage.removeItem(ONBOARDING_COMPLETED_KEY);
} catch (error) {
console.error("[onboardingStorage] Error resetting onboarding progress:", error);
console.error(
"[onboardingStorage] Error resetting onboarding progress:",
error,
);
}
}
@@ -43,7 +49,10 @@ export function markToursTooltipShown(): void {
try {
localStorage.setItem(TOURS_TOOLTIP_KEY, "true");
} catch (error) {
console.error("[onboardingStorage] Error marking tours tooltip as shown:", error);
console.error(
"[onboardingStorage] Error marking tours tooltip as shown:",
error,
);
}
}
@@ -61,7 +70,10 @@ export function migrateFromLegacyPreferences(): void {
const prefs = JSON.parse(prefsRaw) as Record<string, unknown>;
// If user had completed onboarding in old system, mark new system as complete
if (prefs.hasCompletedOnboarding === true || prefs.hasSeenIntroOnboarding === true) {
if (
prefs.hasCompletedOnboarding === true ||
prefs.hasSeenIntroOnboarding === true
) {
markOnboardingCompleted();
}
}
@@ -31,19 +31,27 @@ function hasAuthToken(): boolean {
}
// Get initial runtime state from session storage (survives remounts)
function getInitialRuntimeState(baseState: OnboardingRuntimeState): OnboardingRuntimeState {
function getInitialRuntimeState(
baseState: OnboardingRuntimeState,
): OnboardingRuntimeState {
if (typeof window === "undefined") {
return baseState;
}
try {
const tourRequested = sessionStorage.getItem(SESSION_TOUR_REQUESTED) === "true";
const tourRequested =
sessionStorage.getItem(SESSION_TOUR_REQUESTED) === "true";
const sessionTourType = sessionStorage.getItem(SESSION_TOUR_TYPE);
const tourType =
sessionTourType === "admin" || sessionTourType === "tools" || sessionTourType === "whatsnew"
sessionTourType === "admin" ||
sessionTourType === "tools" ||
sessionTourType === "whatsnew"
? sessionTourType
: "whatsnew";
const selectedRole = sessionStorage.getItem(SESSION_SELECTED_ROLE) as "admin" | "user" | null;
const selectedRole = sessionStorage.getItem(SESSION_SELECTED_ROLE) as
| "admin"
| "user"
| null;
return {
...baseState,
@@ -61,7 +69,10 @@ function persistRuntimeState(state: Partial<OnboardingRuntimeState>): void {
try {
if (state.tourRequested !== undefined) {
sessionStorage.setItem(SESSION_TOUR_REQUESTED, state.tourRequested ? "true" : "false");
sessionStorage.setItem(
SESSION_TOUR_REQUESTED,
state.tourRequested ? "true" : "false",
);
}
if (state.tourType !== undefined) {
sessionStorage.setItem(SESSION_TOUR_TYPE, state.tourType);
@@ -74,7 +85,10 @@ function persistRuntimeState(state: Partial<OnboardingRuntimeState>): void {
}
}
} catch (error) {
console.error("[useOnboardingOrchestrator] Error persisting runtime state:", error);
console.error(
"[useOnboardingOrchestrator] Error persisting runtime state:",
error,
);
}
}
@@ -97,7 +111,10 @@ function parseMfaRequired(settings: string | null | undefined): boolean {
const parsed = JSON.parse(settings) as { mfaRequired?: string };
return parsed.mfaRequired?.toLowerCase() === "true";
} catch (error) {
console.warn("[useOnboardingOrchestrator] Failed to parse account settings JSON:", error);
console.warn(
"[useOnboardingOrchestrator] Failed to parse account settings JSON:",
error,
);
return false;
}
}
@@ -152,14 +169,18 @@ export interface UseOnboardingOrchestratorOptions {
defaultRuntimeState?: OnboardingRuntimeState;
}
export function useOnboardingOrchestrator(options?: UseOnboardingOrchestratorOptions): UseOnboardingOrchestratorResult {
export function useOnboardingOrchestrator(
options?: UseOnboardingOrchestratorOptions,
): UseOnboardingOrchestratorResult {
const defaultState = options?.defaultRuntimeState ?? DEFAULT_RUNTIME_STATE;
const serverExperience = useServerExperience();
const { config, loading: configLoading } = useAppConfig();
const location = useLocation();
const bypassOnboarding = useBypassOnboarding();
const [runtimeState, setRuntimeState] = useState<OnboardingRuntimeState>(() => getInitialRuntimeState(defaultState));
const [runtimeState, setRuntimeState] = useState<OnboardingRuntimeState>(() =>
getInitialRuntimeState(defaultState),
);
const [isPaused, setIsPaused] = useState(false);
const [isInitialized, setIsInitialized] = useState(false);
const [currentStepIndex, setCurrentStepIndex] = useState(-1);
@@ -186,7 +207,8 @@ export function useOnboardingOrchestrator(options?: UseOnboardingOrchestratorOpt
requiresLicense:
!serverExperience.hasPaidLicense &&
(serverExperience.overFreeTierLimit === true ||
(serverExperience.effectiveIsAdmin && serverExperience.userCountResolved)),
(serverExperience.effectiveIsAdmin &&
serverExperience.userCountResolved)),
},
}));
}, [
@@ -217,7 +239,10 @@ export function useOnboardingOrchestrator(options?: UseOnboardingOrchestratorOpt
requiresMfaSetup: parseMfaRequired(accountData.settings),
}));
} catch (error) {
console.log("[OnboardingOrchestrator] Failed to fetch account data for onboarding runtime state:", error);
console.log(
"[OnboardingOrchestrator] Failed to fetch account data for onboarding runtime state:",
error,
);
// Account endpoint failed - user not logged in or security disabled
}
};
@@ -227,17 +252,25 @@ export function useOnboardingOrchestrator(options?: UseOnboardingOrchestratorOpt
}
}, [config?.enableLogin, configLoading]);
const isOnAuthRoute = AUTH_ROUTES.some((route) => location.pathname.startsWith(route));
const isOnAuthRoute = AUTH_ROUTES.some((route) =>
location.pathname.startsWith(route),
);
const loginEnabled = config?.enableLogin === true;
const isUnauthenticatedWithLoginEnabled = loginEnabled && !hasAuthToken();
const shouldBlockOnboarding = bypassOnboarding || isOnAuthRoute || configLoading || isUnauthenticatedWithLoginEnabled;
const shouldBlockOnboarding =
bypassOnboarding ||
isOnAuthRoute ||
configLoading ||
isUnauthenticatedWithLoginEnabled;
const conditionContext = useMemo<OnboardingConditionContext>(
() => ({
...serverExperience,
...runtimeState,
effectiveIsAdmin:
serverExperience.effectiveIsAdmin || (!serverExperience.loginEnabled && runtimeState.selectedRole === "admin"),
serverExperience.effectiveIsAdmin ||
(!serverExperience.loginEnabled &&
runtimeState.selectedRole === "admin"),
}),
[serverExperience, runtimeState],
);
@@ -248,7 +281,10 @@ export function useOnboardingOrchestrator(options?: UseOnboardingOrchestratorOpt
// Wait for config AND admin status before calculating initial step
const adminStatusResolved =
!configLoading && (config?.enableLogin === false || config?.enableLogin === undefined || config?.isAdmin !== undefined);
!configLoading &&
(config?.enableLogin === false ||
config?.enableLogin === undefined ||
config?.isAdmin !== undefined);
useEffect(() => {
if (configLoading || !adminStatusResolved) return;
@@ -276,9 +312,21 @@ export function useOnboardingOrchestrator(options?: UseOnboardingOrchestratorOpt
const totalSteps = activeFlow.length;
const isComplete = isInitialized && (totalSteps === 0 || currentStepIndex >= totalSteps || isOnboardingCompleted());
const currentStep = currentStepIndex >= 0 && currentStepIndex < totalSteps ? activeFlow[currentStepIndex] : null;
const isActive = !shouldBlockOnboarding && !isPaused && !isComplete && isInitialized && currentStep !== null;
const isComplete =
isInitialized &&
(totalSteps === 0 ||
currentStepIndex >= totalSteps ||
isOnboardingCompleted());
const currentStep =
currentStepIndex >= 0 && currentStepIndex < totalSteps
? activeFlow[currentStepIndex]
: null;
const isActive =
!shouldBlockOnboarding &&
!isPaused &&
!isComplete &&
isInitialized &&
currentStep !== null;
const isLoading =
configLoading ||
!adminStatusResolved ||
@@ -322,10 +370,13 @@ export function useOnboardingOrchestrator(options?: UseOnboardingOrchestratorOpt
setCurrentStepIndex(nextIndex);
}, [currentStepIndex, totalSteps]);
const updateRuntimeState = useCallback((updates: Partial<OnboardingRuntimeState>) => {
persistRuntimeState(updates);
setRuntimeState((prev) => ({ ...prev, ...updates }));
}, []);
const updateRuntimeState = useCallback(
(updates: Partial<OnboardingRuntimeState>) => {
persistRuntimeState(updates);
setRuntimeState((prev) => ({ ...prev, ...updates }));
},
[],
);
const refreshFlow = useCallback(() => {
initialIndexSet.current = false;
@@ -11,10 +11,15 @@ interface AnalyticsChoiceSlideProps {
analyticsError?: string | null;
}
export default function AnalyticsChoiceSlide({ analyticsError }: AnalyticsChoiceSlideProps): SlideConfig {
export default function AnalyticsChoiceSlide({
analyticsError,
}: AnalyticsChoiceSlideProps): SlideConfig {
return {
key: "analytics-choice",
title: i18n.t("analytics.title", "Do you want to help make Stirling PDF better?"),
title: i18n.t(
"analytics.title",
"Do you want to help make Stirling PDF better?",
),
body: (
<div className={styles.bodyCopyInner}>
<Trans
@@ -33,13 +38,22 @@ export default function AnalyticsChoiceSlide({ analyticsError }: AnalyticsChoice
<Button
variant="default"
size="sm"
onClick={() => window.open("https://docs.stirlingpdf.com/analytics-telemetry/", "_blank")}
onClick={() =>
window.open(
"https://docs.stirlingpdf.com/analytics-telemetry/",
"_blank",
)
}
rightSection={<OpenInNewIcon style={{ fontSize: 16 }} />}
>
{i18n.t("analytics.learnMore", "Learn more about our analytics")}
</Button>
</div>
{analyticsError && <div style={{ color: "var(--mantine-color-red-6)", marginTop: 12 }}>{analyticsError}</div>}
{analyticsError && (
<div style={{ color: "var(--mantine-color-red-6)", marginTop: 12 }}>
{analyticsError}
</div>
)}
</div>
),
background: {
@@ -65,6 +65,10 @@
transform: translate3d(0, 0, 0);
}
100% {
transform: translate3d(var(--circle-move-x, 40px), var(--circle-move-y, 24px), 0);
transform: translate3d(
var(--circle-move-x, 40px),
var(--circle-move-y, 24px),
0
);
}
}
@@ -14,9 +14,16 @@ interface AnimatedSlideBackgroundComponentProps extends AnimatedSlideBackgroundP
slideKey: string;
}
export default function AnimatedSlideBackground({ gradientStops, circles, isActive }: AnimatedSlideBackgroundComponentProps) {
const [prevGradient, setPrevGradient] = React.useState<[string, string] | null>(null);
const [currentGradient, setCurrentGradient] = React.useState<[string, string]>(gradientStops);
export default function AnimatedSlideBackground({
gradientStops,
circles,
isActive,
}: AnimatedSlideBackgroundComponentProps) {
const [prevGradient, setPrevGradient] = React.useState<
[string, string] | null
>(null);
const [currentGradient, setCurrentGradient] =
React.useState<[string, string]>(gradientStops);
const [isTransitioning, setIsTransitioning] = React.useState(false);
const isFirstMount = React.useRef(true);
@@ -29,7 +36,10 @@ export default function AnimatedSlideBackground({ gradientStops, circles, isActi
}
// Only transition if gradient actually changed
if (currentGradient[0] !== gradientStops[0] || currentGradient[1] !== gradientStops[1]) {
if (
currentGradient[0] !== gradientStops[0] ||
currentGradient[1] !== gradientStops[1]
) {
// Store previous gradient and start transition
setPrevGradient(currentGradient);
setIsTransitioning(true);
@@ -69,10 +79,20 @@ export default function AnimatedSlideBackground({ gradientStops, circles, isActi
style={currentGradientStyle}
/>
{circles.map((circle, index) => {
const { position, size, color, opacity, blur, amplitude = 48, duration = 15, delay = 0 } = circle;
const {
position,
size,
color,
opacity,
blur,
amplitude = 48,
duration = 15,
delay = 0,
} = circle;
const moveX = position === "bottom-left" ? amplitude : -amplitude;
const moveY = position === "bottom-left" ? -amplitude * 0.6 : amplitude * 0.6;
const moveY =
position === "bottom-left" ? -amplitude * 0.6 : amplitude * 0.6;
const circleStyle: CircleStyles = {
width: size,
@@ -98,7 +118,13 @@ export default function AnimatedSlideBackground({ gradientStops, circles, isActi
circleStyle.top = `${defaultOffset + offsetY}px`;
}
return <div key={`circle-${index}-${position}`} className={styles.circle} style={circleStyle} />;
return (
<div
key={`circle-${index}-${position}`}
className={styles.circle}
style={circleStyle}
/>
);
})}
</div>
);
@@ -2,7 +2,10 @@ import React from "react";
import { useTranslation } from "react-i18next";
import { SlideConfig } from "@app/types/types";
import { UNIFIED_CIRCLE_CONFIG } from "@app/components/onboarding/slides/unifiedBackgroundConfig";
import { DesktopInstallTitle, type OSOption } from "@app/components/onboarding/slides/DesktopInstallTitle";
import {
DesktopInstallTitle,
type OSOption,
} from "@app/components/onboarding/slides/DesktopInstallTitle";
export type { OSOption };
@@ -43,7 +43,9 @@ export const DesktopInstallTitle: React.FC<DesktopInstallTitleProps> = ({
const displayLabel = currentOsOption.label || osLabel;
const title = displayLabel
? t("onboarding.desktopInstall.titleWithOs", "Download for {{osLabel}}", { osLabel: displayLabel })
? t("onboarding.desktopInstall.titleWithOs", "Download for {{osLabel}}", {
osLabel: displayLabel,
})
: t("onboarding.desktopInstall.title", "Download");
// If only one option or no options, don't show dropdown
@@ -52,7 +54,15 @@ export const DesktopInstallTitle: React.FC<DesktopInstallTitleProps> = ({
}
return (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: "0.5rem", width: "100%" }}>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: "0.5rem",
width: "100%",
}}
>
<span style={{ whiteSpace: "nowrap" }}>{title}</span>
<Menu position="bottom" offset={5} zIndex={10000}>
<Menu.Target>
@@ -80,7 +90,9 @@ export const DesktopInstallTitle: React.FC<DesktopInstallTitleProps> = ({
backgroundColor: isSelected
? "light-dark(var(--mantine-color-blue-1), var(--mantine-color-blue-8))"
: "transparent",
color: isSelected ? "light-dark(var(--mantine-color-blue-9), var(--mantine-color-white))" : "inherit",
color: isSelected
? "light-dark(var(--mantine-color-blue-9), var(--mantine-color-white))"
: "inherit",
}}
>
{option.label}
@@ -16,10 +16,16 @@ interface FirstLoginSlideProps {
const DEFAULT_PASSWORD = "stirling";
function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials = false }: FirstLoginSlideProps) {
function FirstLoginForm({
username,
onPasswordChanged,
usingDefaultCredentials = false,
}: FirstLoginSlideProps) {
const { t } = useTranslation();
// If using default credentials, pre-fill with "stirling" - user won't see this field
const [currentPassword, setCurrentPassword] = useState(usingDefaultCredentials ? DEFAULT_PASSWORD : "");
const [currentPassword, setCurrentPassword] = useState(
usingDefaultCredentials ? DEFAULT_PASSWORD : "",
);
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [loading, setLoading] = useState(false);
@@ -27,23 +33,39 @@ function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials =
const handleSubmit = async () => {
// Validation
if ((!usingDefaultCredentials && !currentPassword) || !newPassword || !confirmPassword) {
if (
(!usingDefaultCredentials && !currentPassword) ||
!newPassword ||
!confirmPassword
) {
setError(t("firstLogin.allFieldsRequired", "All fields are required"));
return;
}
if (newPassword !== confirmPassword) {
setError(t("firstLogin.passwordsDoNotMatch", "New passwords do not match"));
setError(
t("firstLogin.passwordsDoNotMatch", "New passwords do not match"),
);
return;
}
if (newPassword.length < 8) {
setError(t("firstLogin.passwordTooShort", "Password must be at least 8 characters"));
setError(
t(
"firstLogin.passwordTooShort",
"Password must be at least 8 characters",
),
);
return;
}
if (newPassword === currentPassword) {
setError(t("firstLogin.passwordMustBeDifferent", "New password must be different from current password"));
setError(
t(
"firstLogin.passwordMustBeDifferent",
"New password must be different from current password",
),
);
return;
}
@@ -51,11 +73,18 @@ function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials =
setLoading(true);
setError("");
await accountService.changePasswordOnLogin(currentPassword, newPassword, confirmPassword);
await accountService.changePasswordOnLogin(
currentPassword,
newPassword,
confirmPassword,
);
showToast({
alertType: "success",
title: t("firstLogin.passwordChangedSuccess", "Password changed successfully! Please log in again."),
title: t(
"firstLogin.passwordChangedSuccess",
"Password changed successfully! Please log in again.",
),
});
// Clear form
@@ -73,7 +102,10 @@ function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials =
const axiosError = err as { response?: { data?: { message?: string } } };
setError(
axiosError.response?.data?.message ||
t("firstLogin.passwordChangeFailed", "Failed to change password. Please check your current password."),
t(
"firstLogin.passwordChangeFailed",
"Failed to change password. Please check your current password.",
),
);
} finally {
setLoading(false);
@@ -85,18 +117,33 @@ function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials =
<div className={styles.securityCard}>
<Stack gap="md">
<div className={styles.securityAlertRow}>
<LocalIcon icon="info-rounded" width={20} height={20} style={{ color: "#3B82F6", flexShrink: 0 }} />
<LocalIcon
icon="info-rounded"
width={20}
height={20}
style={{ color: "#3B82F6", flexShrink: 0 }}
/>
<span>
{t("firstLogin.welcomeMessage", "For security reasons, you must change your password on your first login.")}
{t(
"firstLogin.welcomeMessage",
"For security reasons, you must change your password on your first login.",
)}
</span>
</div>
<Text size="sm" fw={500}>
{t("firstLogin.loggedInAs", "Logged in as")}: <strong>{username}</strong>
{t("firstLogin.loggedInAs", "Logged in as")}:{" "}
<strong>{username}</strong>
</Text>
{error && (
<Alert icon={<LocalIcon icon="error-rounded" width="1rem" height="1rem" />} color="red" variant="light">
<Alert
icon={
<LocalIcon icon="error-rounded" width="1rem" height="1rem" />
}
color="red"
variant="light"
>
{error}
</Alert>
)}
@@ -105,7 +152,10 @@ function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials =
{!usingDefaultCredentials && (
<PasswordInput
label={t("firstLogin.currentPassword", "Current Password")}
placeholder={t("firstLogin.enterCurrentPassword", "Enter your current password")}
placeholder={t(
"firstLogin.enterCurrentPassword",
"Enter your current password",
)}
value={currentPassword}
onChange={(e) => setCurrentPassword(e.currentTarget.value)}
required
@@ -117,7 +167,10 @@ function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials =
<PasswordInput
label={t("firstLogin.newPassword", "New Password")}
placeholder={t("firstLogin.enterNewPassword", "Enter new password (min 8 characters)")}
placeholder={t(
"firstLogin.enterNewPassword",
"Enter new password (min 8 characters)",
)}
value={newPassword}
onChange={(e) => setNewPassword(e.currentTarget.value)}
minLength={8}
@@ -129,7 +182,10 @@ function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials =
<PasswordInput
label={t("firstLogin.confirmPassword", "Confirm New Password")}
placeholder={t("firstLogin.reEnterNewPassword", "Re-enter new password")}
placeholder={t(
"firstLogin.reEnterNewPassword",
"Re-enter new password",
)}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.currentTarget.value)}
required
@@ -143,7 +199,12 @@ function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials =
fullWidth
onClick={handleSubmit}
loading={loading}
disabled={!newPassword || !confirmPassword || newPassword.length < 8 || confirmPassword.length < 8}
disabled={
!newPassword ||
!confirmPassword ||
newPassword.length < 8 ||
confirmPassword.length < 8
}
size="md"
mt="xs"
>
@@ -1,5 +1,20 @@
import { useCallback, useEffect, useRef, useState, type FormEvent } from "react";
import { Alert, Box, Button, Group, Loader, Stack, Text, TextInput } from "@mantine/core";
import {
useCallback,
useEffect,
useRef,
useState,
type FormEvent,
} from "react";
import {
Alert,
Box,
Button,
Group,
Loader,
Stack,
Text,
TextInput,
} from "@mantine/core";
import { QRCodeSVG } from "qrcode.react";
import { SlideConfig } from "@app/types/types";
import { UNIFIED_CIRCLE_CONFIG } from "@app/components/onboarding/slides/unifiedBackgroundConfig";
@@ -16,7 +31,9 @@ interface MFASetupSlideProps {
}
function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) {
const [mfaSetupData, setMfaSetupData] = useState<MfaSetupResponse | null>(null);
const [mfaSetupData, setMfaSetupData] = useState<MfaSetupResponse | null>(
null,
);
const [mfaSetupCode, setMfaSetupCode] = useState("");
const [mfaError, setMfaError] = useState("");
const [mfaLoading, setMfaLoading] = useState(false);
@@ -27,7 +44,10 @@ function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) {
const accountLogout = useAccountLogout();
const qrLogoSrc = `${BASE_PATH}/modern-logo/StirlingPDFLogoNoTextDark.svg`;
const normalizeMfaCode = useCallback((value: string) => value.replace(/\D/g, "").slice(0, 6), []);
const normalizeMfaCode = useCallback(
(value: string) => value.replace(/\D/g, "").slice(0, 6),
[],
);
const fetchMfaSetup = useCallback(async () => {
try {
@@ -38,7 +58,10 @@ function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) {
setMfaSetupData(data);
} catch (err) {
const axiosError = err as { response?: { data?: { error?: string } } };
setMfaError(axiosError.response?.data?.error || "Unable to start two-factor setup. Please try again.");
setMfaError(
axiosError.response?.data?.error ||
"Unable to start two-factor setup. Please try again.",
);
} finally {
setMfaLoading(false);
}
@@ -84,7 +107,8 @@ function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) {
} catch (err) {
const axiosError = err as { response?: { data?: { error?: string } } };
setMfaError(
axiosError.response?.data?.error || "Unable to enable two-factor authentication. Check the code and try again.",
axiosError.response?.data?.error ||
"Unable to enable two-factor authentication. Check the code and try again.",
);
} finally {
setSubmitting(false);
@@ -137,12 +161,17 @@ function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) {
<div className={styles.mfaCard}>
<Stack gap="md">
<Text size="sm" c="dimmed">
Secure your account by linking an authenticator app. Scan the QR code or enter the setup key, then confirm the
6-digit code to finish.
Secure your account by linking an authenticator app. Scan the QR
code or enter the setup key, then confirm the 6-digit code to
finish.
</Text>
{mfaError && (
<Alert icon={<LocalIcon icon="error" width={16} height={16} />} color="red" variant="light">
<Alert
icon={<LocalIcon icon="error" width={16} height={16} />}
color="red"
variant="light"
>
{mfaError}
</Alert>
)}
@@ -163,7 +192,9 @@ function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) {
label="Authentication code"
placeholder="123456"
value={mfaSetupCode}
onChange={(event) => setMfaSetupCode(normalizeMfaCode(event.currentTarget.value))}
onChange={(event) =>
setMfaSetupCode(normalizeMfaCode(event.currentTarget.value))
}
inputMode="numeric"
maxLength={6}
minLength={6}
@@ -179,7 +210,13 @@ function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) {
>
Regenerate QR code
</Button>
<Button type="submit" loading={submitting} disabled={!isReady || setupComplete || mfaSetupCode.length < 6}>
<Button
type="submit"
loading={submitting}
disabled={
!isReady || setupComplete || mfaSetupCode.length < 6
}
>
Enable MFA
</Button>
<Button type="button" variant="light" onClick={onLogout}>
@@ -200,7 +237,9 @@ function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) {
);
}
export default function MFASetupSlide({ onMfaSetupComplete }: MFASetupSlideProps = {}): SlideConfig {
export default function MFASetupSlide({
onMfaSetupComplete,
}: MFASetupSlideProps = {}): SlideConfig {
return {
key: "mfa-setup-slide",
title: "Multi-Factor Authentication Setup",
@@ -22,7 +22,10 @@ const PlanOverviewTitle: React.FC<{ isAdmin: boolean }> = ({ isAdmin }) => {
);
};
const AdminOverviewBody: React.FC<{ freeTierLimit: number; loginEnabled: boolean }> = ({ freeTierLimit, loginEnabled }) => {
const AdminOverviewBody: React.FC<{
freeTierLimit: number;
loginEnabled: boolean;
}> = ({ freeTierLimit, loginEnabled }) => {
const adminBodyKey = loginEnabled
? "onboarding.planOverview.adminBodyLoginEnabled"
: "onboarding.planOverview.adminBodyLoginDisabled";
@@ -32,7 +35,12 @@ const AdminOverviewBody: React.FC<{ freeTierLimit: number; loginEnabled: boolean
: "Once you enable login mode, you can manage users, configure settings, and monitor server health. The first <strong>{{freeTierLimit}}</strong> people on your server get to use Stirling free of charge.";
return (
<Trans i18nKey={adminBodyKey} values={{ freeTierLimit }} components={{ strong: <strong /> }} defaults={defaultValue} />
<Trans
i18nKey={adminBodyKey}
values={{ freeTierLimit }}
components={{ strong: <strong /> }}
defaults={defaultValue}
/>
);
};
@@ -48,11 +56,19 @@ const UserOverviewBody: React.FC = () => {
);
};
const PlanOverviewBody: React.FC<{ isAdmin: boolean; freeTierLimit: number; loginEnabled: boolean }> = ({
isAdmin,
freeTierLimit,
loginEnabled,
}) => (isAdmin ? <AdminOverviewBody freeTierLimit={freeTierLimit} loginEnabled={loginEnabled} /> : <UserOverviewBody />);
const PlanOverviewBody: React.FC<{
isAdmin: boolean;
freeTierLimit: number;
loginEnabled: boolean;
}> = ({ isAdmin, freeTierLimit, loginEnabled }) =>
isAdmin ? (
<AdminOverviewBody
freeTierLimit={freeTierLimit}
loginEnabled={loginEnabled}
/>
) : (
<UserOverviewBody />
);
export default function PlanOverviewSlide({
isAdmin,
@@ -64,7 +80,13 @@ export default function PlanOverviewSlide({
return {
key: isAdmin ? "admin-overview" : "plan-overview",
title: <PlanOverviewTitle isAdmin={isAdmin} />,
body: <PlanOverviewBody isAdmin={isAdmin} freeTierLimit={freeTierLimit} loginEnabled={loginEnabled} />,
body: (
<PlanOverviewBody
isAdmin={isAdmin}
freeTierLimit={freeTierLimit}
loginEnabled={loginEnabled}
/>
),
background: {
gradientStops: isAdmin ? ["#4F46E5", "#0EA5E9"] : ["#F97316", "#EF4444"],
circles: UNIFIED_CIRCLE_CONFIG,
@@ -11,7 +11,10 @@ interface SecurityCheckSlideProps {
onRoleSelect: (role: "admin" | "user" | null) => void;
}
export default function SecurityCheckSlide({ selectedRole, onRoleSelect }: SecurityCheckSlideProps): SlideConfig {
export default function SecurityCheckSlide({
selectedRole,
onRoleSelect,
}: SecurityCheckSlideProps): SlideConfig {
return {
key: "security-check",
title: "Security Check",
@@ -19,7 +22,12 @@ export default function SecurityCheckSlide({ selectedRole, onRoleSelect }: Secur
<div className={styles.securitySlideContent}>
<div className={styles.securityCard}>
<div className={styles.securityAlertRow}>
<LocalIcon icon="error" width={20} height={20} style={{ color: "#F04438", flexShrink: 0 }} />
<LocalIcon
icon="error"
width={20}
height={20}
style={{ color: "#F04438", flexShrink: 0 }}
/>
<span>
{i18n.t(
"onboarding.securityCheck.message",
@@ -35,7 +43,9 @@ export default function SecurityCheckSlide({ selectedRole, onRoleSelect }: Secur
{ value: "admin", label: "Admin" },
{ value: "user", label: "User" },
]}
onChange={(value) => onRoleSelect((value as "admin" | "user") ?? null)}
onChange={(value) =>
onRoleSelect((value as "admin" | "user") ?? null)
}
comboboxProps={{ withinPortal: true, zIndex: 5000 }}
styles={{
input: {
@@ -10,11 +10,14 @@ interface ServerLicenseSlideProps {
const DEFAULT_FREE_TIER_LIMIT = 5;
export default function ServerLicenseSlide({ licenseNotice }: ServerLicenseSlideProps = {}): SlideConfig {
export default function ServerLicenseSlide({
licenseNotice,
}: ServerLicenseSlideProps = {}): SlideConfig {
const freeTierLimit = licenseNotice?.freeTierLimit ?? DEFAULT_FREE_TIER_LIMIT;
const totalUsers = licenseNotice?.totalUsers ?? null;
const isOverLimit = licenseNotice?.isOverLimit ?? false;
const formattedTotalUsers = totalUsers != null ? totalUsers.toLocaleString() : null;
const formattedTotalUsers =
totalUsers != null ? totalUsers.toLocaleString() : null;
const overLimitUserCopy = formattedTotalUsers ?? `more than ${freeTierLimit}`;
const title = isOverLimit
? i18n.t("onboarding.serverLicense.overLimitTitle", "Server License Needed")
@@ -50,7 +53,9 @@ export default function ServerLicenseSlide({ licenseNotice }: ServerLicenseSlide
title,
body,
background: {
gradientStops: isOverLimit ? ["#F472B6", "#8B5CF6"] : ["#F97316", "#F59E0B"],
gradientStops: isOverLimit
? ["#F472B6", "#8B5CF6"]
: ["#F97316", "#F59E0B"],
circles: UNIFIED_CIRCLE_CONFIG,
},
};
@@ -12,6 +12,10 @@ export const addGlowToElements = (selectors: string[]) => {
};
export const removeAllGlows = () => {
document.querySelectorAll(".tour-content-glow").forEach((el) => el.classList.remove("tour-content-glow"));
document.querySelectorAll(".tour-nav-glow").forEach((el) => el.classList.remove("tour-nav-glow"));
document
.querySelectorAll(".tour-content-glow")
.forEach((el) => el.classList.remove("tour-content-glow"));
document
.querySelectorAll(".tour-nav-glow")
.forEach((el) => el.classList.remove("tour-nav-glow"));
};
@@ -38,7 +38,9 @@ function setStoredBypass(enabled: boolean): void {
*/
export function useBypassOnboarding(): boolean {
const location = useLocation();
const [bypassOnboarding, setBypassOnboarding] = useState<boolean>(() => readStoredBypass());
const [bypassOnboarding, setBypassOnboarding] = useState<boolean>(() =>
readStoredBypass(),
);
const shouldBypassFromSearch = useMemo(() => {
try {
@@ -36,7 +36,10 @@ export function useOnboardingDownload(): UseOnboardingDownloadResult {
case "windows":
return { label: "Windows", url: DOWNLOAD_URLS.WINDOWS };
case "mac-apple":
return { label: "Mac (Apple Silicon)", url: DOWNLOAD_URLS.MAC_APPLE_SILICON };
return {
label: "Mac (Apple Silicon)",
url: DOWNLOAD_URLS.MAC_APPLE_SILICON,
};
case "mac-intel":
return { label: "Mac (Intel)", url: DOWNLOAD_URLS.MAC_INTEL };
case "linux-x64":
@@ -51,8 +54,16 @@ export function useOnboardingDownload(): UseOnboardingDownloadResult {
() =>
[
{ label: "Windows", url: DOWNLOAD_URLS.WINDOWS, value: "windows" },
{ label: "Mac (Apple Silicon)", url: DOWNLOAD_URLS.MAC_APPLE_SILICON, value: "mac-apple" },
{ label: "Mac (Intel)", url: DOWNLOAD_URLS.MAC_INTEL, value: "mac-intel" },
{
label: "Mac (Apple Silicon)",
url: DOWNLOAD_URLS.MAC_APPLE_SILICON,
value: "mac-apple",
},
{
label: "Mac (Intel)",
url: DOWNLOAD_URLS.MAC_INTEL,
value: "mac-intel",
},
{ label: "Linux", url: DOWNLOAD_URLS.LINUX_DOCS, value: "linux" },
].filter((opt) => opt.url),
[],
@@ -14,7 +14,9 @@ export function useServerLicenseRequest(): {
closeLicenseSlide: () => void;
} {
const [showLicenseSlide, setShowLicenseSlide] = useState(false);
const [licenseNotice, setLicenseNotice] = useState<OnboardingRuntimeState["licenseNotice"] | null>(null);
const [licenseNotice, setLicenseNotice] = useState<
OnboardingRuntimeState["licenseNotice"] | null
>(null);
useEffect(() => {
if (typeof window === "undefined") return;
@@ -35,7 +37,11 @@ export function useServerLicenseRequest(): {
};
window.addEventListener(SERVER_LICENSE_REQUEST_EVENT, handleLicenseRequest);
return () => window.removeEventListener(SERVER_LICENSE_REQUEST_EVENT, handleLicenseRequest);
return () =>
window.removeEventListener(
SERVER_LICENSE_REQUEST_EVENT,
handleLicenseRequest,
);
}, []);
const closeLicenseSlide = useCallback(() => {
@@ -51,7 +57,8 @@ export function useTourRequest(): {
clearTourRequest: () => void;
} {
const [tourRequested, setTourRequested] = useState(false);
const [requestedTourType, setRequestedTourType] = useState<TourType>("whatsnew");
const [requestedTourType, setRequestedTourType] =
useState<TourType>("whatsnew");
useEffect(() => {
if (typeof window === "undefined") return;
@@ -63,7 +70,8 @@ export function useTourRequest(): {
};
window.addEventListener(START_TOUR_EVENT, handleTourRequest);
return () => window.removeEventListener(START_TOUR_EVENT, handleTourRequest);
return () =>
window.removeEventListener(START_TOUR_EVENT, handleTourRequest);
}, []);
const clearTourRequest = useCallback(() => {
@@ -36,7 +36,10 @@ interface CreateUserStepsConfigArgs {
actions: UserStepActions;
}
export function createUserStepsConfig({ t, actions }: CreateUserStepsConfigArgs): Record<TourStep, StepType> {
export function createUserStepsConfig({
t,
actions,
}: CreateUserStepsConfigArgs): Record<TourStep, StepType> {
const {
saveWorkbenchState,
closeFilesModal,
@@ -1,7 +1,11 @@
import type { StepType } from "@reactour/tour";
import type { TFunction } from "i18next";
async function waitForElement(selector: string, timeoutMs = 7000, intervalMs = 100): Promise<void> {
async function waitForElement(
selector: string,
timeoutMs = 7000,
intervalMs = 100,
): Promise<void> {
if (typeof document === "undefined") return;
const start = Date.now();
// Immediate hit
@@ -19,7 +23,11 @@ async function waitForElement(selector: string, timeoutMs = 7000, intervalMs = 1
});
}
async function waitForHighlightable(selector: string, timeoutMs = 7000, intervalMs = 500): Promise<void> {
async function waitForHighlightable(
selector: string,
timeoutMs = 7000,
intervalMs = 500,
): Promise<void> {
if (typeof document === "undefined") return;
const start = Date.now();
@@ -68,7 +76,10 @@ interface CreateWhatsNewStepsConfigArgs {
actions: WhatsNewStepActions;
}
export function createWhatsNewStepsConfig({ t, actions }: CreateWhatsNewStepsConfigArgs): Record<WhatsNewTourStep, StepType> {
export function createWhatsNewStepsConfig({
t,
actions,
}: CreateWhatsNewStepsConfigArgs): Record<WhatsNewTourStep, StepType> {
const {
saveWorkbenchState,
closeFilesModal,
@@ -128,7 +139,10 @@ export function createWhatsNewStepsConfig({ t, actions }: CreateWhatsNewStepsCon
},
[WhatsNewTourStep.RIGHT_RAIL]: {
selector: '[data-tour="right-rail-controls"]',
highlightedSelectors: ['[data-tour="right-rail-controls"]', '[data-tour="right-rail-settings"]'],
highlightedSelectors: [
'[data-tour="right-rail-controls"]',
'[data-tour="right-rail-settings"]',
],
content: t(
"onboarding.whatsNew.rightRail",
"The <strong>Right Rail</strong> holds quick actions to select files, change theme or language, and download results.",
@@ -157,7 +171,10 @@ export function createWhatsNewStepsConfig({ t, actions }: CreateWhatsNewStepsCon
},
[WhatsNewTourStep.PAGE_EDITOR_VIEW]: {
selector: '[data-tour="view-switcher"]',
content: t("onboarding.whatsNew.pageEditorView", "Switch to the Page Editor to reorder, rotate, or delete pages."),
content: t(
"onboarding.whatsNew.pageEditorView",
"Switch to the Page Editor to reorder, rotate, or delete pages.",
),
position: "bottom",
padding: 8,
action: async () => {