mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59fa915bf9 | ||
|
|
c879b56fac | ||
|
|
1f27655c1b | ||
|
|
2a403d3210 | ||
|
|
d98a4875db | ||
|
|
8ec80eb9fc | ||
|
|
e97c16db92 | ||
|
|
7058a41af9 | ||
|
|
366516dcdf | ||
|
|
422df509f1 | ||
|
|
9562c1622a |
@@ -1,3 +1,4 @@
|
||||
import { existsSync, statSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import type { StorybookConfig } from "@storybook/react-vite";
|
||||
import tsconfigPaths from "vite-tsconfig-paths";
|
||||
@@ -10,6 +11,45 @@ import tsconfigPaths from "vite-tsconfig-paths";
|
||||
* the portal layer at editor/src/portal/). MDX docs pages live in
|
||||
* editor/src/portal/docs/.
|
||||
*/
|
||||
|
||||
/** Layer search order for @app/*, mirroring each flavour's vite tsconfig. */
|
||||
const FLAVOUR_LAYERS: Record<string, string[]> = {
|
||||
desktop: ["desktop", "cloud", "proprietary", "core"],
|
||||
saas: ["saas", "cloud", "proprietary", "core"],
|
||||
cloud: ["cloud", "proprietary", "core"],
|
||||
prototypes: ["prototypes", "proprietary", "core"],
|
||||
};
|
||||
|
||||
const SUFFIXES = ["", ".tsx", ".ts", "/index.tsx", "/index.ts"];
|
||||
|
||||
function flavourAppAlias() {
|
||||
return {
|
||||
name: "storybook-app-alias-by-flavour",
|
||||
// Ahead of vite-tsconfig-paths, which would otherwise answer first with the
|
||||
// proprietary order.
|
||||
enforce: "pre" as const,
|
||||
resolveId(source: string, importer: string | undefined) {
|
||||
if (!importer || !source.startsWith("@app/")) return null;
|
||||
const layer = importer
|
||||
.split("\\")
|
||||
.join("/")
|
||||
.match(/\/editor\/src\/(desktop|saas|cloud|prototypes)\//)?.[1];
|
||||
if (!layer) return null;
|
||||
const rest = source.slice("@app/".length);
|
||||
for (const candidate of FLAVOUR_LAYERS[layer]) {
|
||||
for (const suffix of SUFFIXES) {
|
||||
const full = resolve(
|
||||
__dirname,
|
||||
`../editor/src/${candidate}/${rest}${suffix}`,
|
||||
);
|
||||
if (existsSync(full) && statSync(full).isFile()) return full;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const config: StorybookConfig = {
|
||||
stories: [
|
||||
"../editor/src/portal/**/*.mdx",
|
||||
@@ -53,6 +93,12 @@ const config: StorybookConfig = {
|
||||
// shared Storybook can host editor components without duplicating the alias
|
||||
// map here.
|
||||
config.plugins = config.plugins ?? [];
|
||||
// Stories under desktop/, saas/, cloud/ and prototypes/ import @app/* too,
|
||||
// but each of those builds resolves it against its own layer first — an
|
||||
// order the single tsconfig below cannot express. Resolving by the
|
||||
// importer's layer lets those stories load without changing what @app/*
|
||||
// means for core, proprietary or portal stories, which never match here.
|
||||
config.plugins.push(flavourAppAlias());
|
||||
config.plugins.push(
|
||||
tsconfigPaths({
|
||||
projects: [
|
||||
|
||||
@@ -18,7 +18,14 @@ import { TierProvider, type Tier } from "@portal/contexts/TierContext";
|
||||
import { LinkProvider, type LinkState } from "@portal/contexts/LinkContext";
|
||||
import { ThemeProvider, useTheme } from "@portal/contexts/ThemeContext";
|
||||
import { UIProvider } from "@portal/contexts/UIContext";
|
||||
import { PreferencesProvider } from "@core/contexts/PreferencesContext";
|
||||
import { SidebarProvider } from "@core/contexts/SidebarContext";
|
||||
import { SuiProvider } from "@portal/theme/SuiProvider";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import {
|
||||
mantineTheme as editorMantineTheme,
|
||||
editorCssVariablesResolver,
|
||||
} from "@core/theme/mantineTheme";
|
||||
import { handlers } from "@portal/mocks/handlers";
|
||||
import { configureSupabase } from "@proprietary/auth/supabase/supabaseClient";
|
||||
import i18next from "i18next";
|
||||
@@ -29,6 +36,11 @@ import { rtlLanguages, supportedLanguages } from "@core/i18n/languages";
|
||||
import "@mantine/core/styles.css";
|
||||
import "@core/tokens/tokens.css";
|
||||
import "@core/theme/index.css";
|
||||
// The editor's Mantine theme resolves its palette through the --color-* vocab
|
||||
// defined here. The app picks this up via styles/tailwind.css; Storybook has no
|
||||
// tailwind entry, so without it every var(--color-*) in the theme is undefined
|
||||
// and Mantine silently falls back to its stock palette.
|
||||
import "@core/styles/theme.css";
|
||||
import "@core/tokens/base.css";
|
||||
|
||||
// Storybook-only: bundle every shipped locale's TOML at build time via a ?raw
|
||||
@@ -190,6 +202,37 @@ const withLocale: Decorator = (Story, context) => {
|
||||
return <Story />;
|
||||
};
|
||||
|
||||
/**
|
||||
* Applies the Mantine theme the story's component actually runs under in the
|
||||
* app: PortalApp wraps the Processor in SuiProvider, while the editor wraps
|
||||
* everything else in its own ThemeProvider. Getting this wrong is not just
|
||||
* cosmetic — the two themes carry different neutral ramps, so rendering an
|
||||
* editor component under the Processor's theme drops it onto Mantine's stock
|
||||
* greys and reports contrast failures the app doesn't have.
|
||||
*/
|
||||
function StoryTheme({
|
||||
isPortalStory,
|
||||
colorScheme,
|
||||
children,
|
||||
}: {
|
||||
isPortalStory: boolean;
|
||||
colorScheme: "light" | "dark";
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
if (isPortalStory) {
|
||||
return <SuiProvider colorScheme={colorScheme}>{children}</SuiProvider>;
|
||||
}
|
||||
return (
|
||||
<MantineProvider
|
||||
theme={editorMantineTheme}
|
||||
cssVariablesResolver={editorCssVariablesResolver}
|
||||
forceColorScheme={colorScheme}
|
||||
>
|
||||
{children}
|
||||
</MantineProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const withProviders: Decorator = (Story, context) => {
|
||||
const tier = (context.globals.tier as Tier) ?? "pro";
|
||||
const linkState =
|
||||
@@ -201,25 +244,36 @@ const withProviders: Decorator = (Story, context) => {
|
||||
// anything that isn't "dark" as light — matching the addon's own
|
||||
// `selected || defaultTheme` fallback where defaultTheme is light.
|
||||
const colorScheme = context.globals.theme === "dark" ? "dark" : "light";
|
||||
// Storybook titles are the routing key here: the Processor's stories are all
|
||||
// filed under "Portal/".
|
||||
const isPortalStory = (context.title ?? "").startsWith("Portal/");
|
||||
return (
|
||||
<MemoryRouter initialEntries={["/"]}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>
|
||||
<SchemeSetup scheme={colorScheme} />
|
||||
<ThemeBridge theme={colorScheme}>
|
||||
<SuiProvider colorScheme={colorScheme}>
|
||||
<StoryTheme isPortalStory={isPortalStory} colorScheme={colorScheme}>
|
||||
{/* LinkProvider must wrap TierProvider: TierContext derives its tier
|
||||
from useLink() (matches App.tsx's nesting). */}
|
||||
<LinkProvider key={linkState} initialState={linkState}>
|
||||
<TierKey tier={tier}>
|
||||
<UIProvider>
|
||||
<Suspense fallback={null}>
|
||||
<Story />
|
||||
</Suspense>
|
||||
</UIProvider>
|
||||
{/* Tooltip reads the user's logo preference and the sidebar
|
||||
geometry it positions against. It is used by ~100
|
||||
components, so without these a story that renders one
|
||||
throws. The real app always has both mounted. */}
|
||||
<PreferencesProvider>
|
||||
<SidebarProvider>
|
||||
<UIProvider>
|
||||
<Suspense fallback={null}>
|
||||
<Story />
|
||||
</Suspense>
|
||||
</UIProvider>
|
||||
</SidebarProvider>
|
||||
</PreferencesProvider>
|
||||
</TierKey>
|
||||
</LinkProvider>
|
||||
</SuiProvider>
|
||||
</StoryTheme>
|
||||
</ThemeBridge>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
@@ -246,6 +300,14 @@ const preview: Preview = {
|
||||
// any violation. Context is left at the addon default (the document root)
|
||||
// so it resolves under both the Storybook UI and the Vitest browser mount.
|
||||
test: "error",
|
||||
context: {
|
||||
// Nodes carrying this attribute render a facsimile of the user's own
|
||||
// document — their stamp text, their watermark, in the colour and
|
||||
// opacity they chose. WCAG contrast governs the interface, not the
|
||||
// content authored through it, and the controls that set those values
|
||||
// are checked normally.
|
||||
exclude: ["[data-user-content-preview]"],
|
||||
},
|
||||
},
|
||||
},
|
||||
globalTypes: {
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
#!/usr/bin/env node
|
||||
// Storybook coverage report — which rendered surfaces have a story, and what
|
||||
// stands between the ones that don't and having one. Modes:
|
||||
//
|
||||
// node storybook-coverage.mjs summary by area (report only)
|
||||
// node storybook-coverage.mjs --todo every uncovered surface, with the
|
||||
// work each needs
|
||||
// node storybook-coverage.mjs --area core/components/tools
|
||||
//
|
||||
// Coverage is counted by *import*, not by an adjacent .stories.tsx: several
|
||||
// components are covered by a shared story file (MantineForms covers Select,
|
||||
// MultiSelect, NumberInput and ColorInput between them), and counting siblings
|
||||
// reports those as gaps and invites duplicate stories.
|
||||
//
|
||||
// Each uncovered surface is classified by what a story would have to supply:
|
||||
//
|
||||
// props no context to supply. Note this means "no provider needed", not
|
||||
// "cheap" — a props-only component can still be expensive to
|
||||
// story if its props are heavy (ButtonAppearanceOverlay wants
|
||||
// real PDF bytes; AppConfigModalLazy lazy-loads the whole
|
||||
// settings tree). Read the props before assuming it is quick.
|
||||
// context it (or something it renders) reads a React context. The cheap
|
||||
// fix is usually to export the context and hand the story the
|
||||
// slice the component actually touches, rather than mounting the
|
||||
// provider and whatever chain sits behind it.
|
||||
// data it fetches, so the story needs MSW handlers
|
||||
// router it reads router state
|
||||
//
|
||||
// Not counted as surfaces at all: providers, contexts, gates, routers, test
|
||||
// helpers, and modules that return a config object rather than markup.
|
||||
//
|
||||
// Known blocker — four flavours cannot be storied as things stand.
|
||||
//
|
||||
// Storybook resolves @app/* through editor/tsconfig.proprietary.vite.json,
|
||||
// which maps it to src/proprietary/* then src/core/* and excludes src/desktop.
|
||||
// A file in another flavour that imports an @app/* asset living only in its own
|
||||
// tree therefore fails to resolve, and the story file does not load at all:
|
||||
//
|
||||
// desktop 80 of 152 @app/ imports unresolvable
|
||||
// saas 54 of 232
|
||||
// cloud 28 of 77
|
||||
// prototypes 4 of 40
|
||||
// portal-saas 0 of 7 (fine)
|
||||
//
|
||||
// That accounts for the 0% areas below — it is a build-config gap, not
|
||||
// neglect. Closing it means either per-flavour alias projects in
|
||||
// .storybook/main.ts or hoisting the shared assets, and it is a decision with
|
||||
// blast radius across every existing story, so it is deliberately not made
|
||||
// here.
|
||||
//
|
||||
// Run from frontend/.
|
||||
|
||||
import { readFileSync, readdirSync } from "node:fs";
|
||||
import { join, relative, resolve } from "node:path";
|
||||
|
||||
const SRC = resolve(process.cwd(), "editor/src");
|
||||
const args = process.argv.slice(2);
|
||||
const wantTodo = args.includes("--todo");
|
||||
const areaFilter = args.includes("--area")
|
||||
? args[args.indexOf("--area") + 1]
|
||||
: null;
|
||||
|
||||
/* ── file walk ────────────────────────────────────────────────────────────── */
|
||||
|
||||
function walk(dir, out = []) {
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = join(dir, entry.name);
|
||||
if (entry.isDirectory()) walk(full, out);
|
||||
else if (entry.name.endsWith(".tsx")) out.push(full);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const all = walk(SRC);
|
||||
const rel = (f) => relative(SRC, f).split("\\").join("/");
|
||||
const storyFiles = all.filter((f) => f.endsWith(".stories.tsx"));
|
||||
const sources = all.filter(
|
||||
(f) => !f.endsWith(".stories.tsx") && !f.endsWith(".test.tsx"),
|
||||
);
|
||||
|
||||
/* ── what the stories already reach ───────────────────────────────────────── */
|
||||
|
||||
const importedNames = new Set();
|
||||
const importedPaths = new Set();
|
||||
for (const f of storyFiles) {
|
||||
const src = readFileSync(f, "utf8");
|
||||
for (const m of src.matchAll(
|
||||
/import\s+(?:type\s+)?(?:\{([^}]*)\}|(\w+))\s*(?:,\s*\{([^}]*)\})?\s*from\s+["']([^"']+)/g,
|
||||
)) {
|
||||
for (const group of [m[1], m[3]]) {
|
||||
if (!group) continue;
|
||||
for (const name of group.split(","))
|
||||
importedNames.add(
|
||||
name.trim().split(" as ")[0].replace("type ", "").trim(),
|
||||
);
|
||||
}
|
||||
if (m[2]) importedNames.add(m[2]);
|
||||
importedPaths.add(m[4]);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── classification ───────────────────────────────────────────────────────── */
|
||||
|
||||
// Bridge: the viewer's *APIBridge components register an API into context and
|
||||
// render null — wiring, like the rest of these.
|
||||
const INFRA_NAME =
|
||||
/(Provider|Providers|Context|Gate|Boundary|Mount|Router|Guard|Bridge)\.tsx$/;
|
||||
const INFRA_DIR = /\/(contexts|test|tests|mocks|hooks|types|utils|api|data)\//;
|
||||
const RENDERS = /return\s*\(?\s*<|=>\s*\(?\s*</;
|
||||
// A module whose exported function returns a config object, not markup.
|
||||
const CONFIG_FACTORY = /:\s*(SlideConfig|ToolFlowConfig|\w+Config)\s*\{/;
|
||||
const DATA = /\buse(Query|Mutation|SWR|InfiniteQuery)\b|\bfetch\w*\(/;
|
||||
const ROUTER = /\buse(Navigate|Params|Location|SearchParams)\b/;
|
||||
const CONTEXT = /\buse[A-Z]\w*\(/g;
|
||||
// Hooks that are plainly not context reads.
|
||||
const LOCAL_HOOK =
|
||||
/^use(State|Effect|Memo|Callback|Ref|Id|Reducer|Context|Translation|LayoutEffect|ImperativeHandle|Transition|DeferredValue|SyncExternalStore|Debounced\w*|Media\w*|Disclosure|Form)$/;
|
||||
// Contexts .storybook/preview.tsx already mounts for every story. A component
|
||||
// that reads only these needs no fixture work, so it counts as props-level.
|
||||
const HARNESS_PROVIDED =
|
||||
/^use(Preferences|SidebarContext|Tier|Link|UI|Theme|QueryClient|Navigate|Location|Params|SearchParams)$/;
|
||||
|
||||
const byPath = new Map(sources.map((f) => [rel(f), f]));
|
||||
|
||||
function localImports(src, fromRel) {
|
||||
const out = [];
|
||||
for (const m of src.matchAll(
|
||||
/from\s+["'](@app\/|@core\/|\.\.?\/)([^"']+)/g,
|
||||
)) {
|
||||
const spec = m[1] + m[2];
|
||||
const guess = spec.replace(/^@app\//, "core/").replace(/^@core\//, "core/");
|
||||
for (const cand of [`${guess}.tsx`, `${guess}/index.tsx`]) {
|
||||
if (byPath.has(cand)) out.push(cand);
|
||||
}
|
||||
void fromRel;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Does this file, or anything it renders, read a context? Depth-limited. */
|
||||
function needsContext(relPath, seen = new Set(), depth = 0) {
|
||||
if (depth > 3 || seen.has(relPath)) return false;
|
||||
seen.add(relPath);
|
||||
const file = byPath.get(relPath);
|
||||
if (!file) return false;
|
||||
const src = readFileSync(file, "utf8");
|
||||
for (const m of src.matchAll(CONTEXT)) {
|
||||
const name = m[0].slice(0, -1);
|
||||
if (!LOCAL_HOOK.test(name) && !HARNESS_PROVIDED.test(name)) return true;
|
||||
}
|
||||
return localImports(src, relPath).some((child) =>
|
||||
needsContext(child, seen, depth + 1),
|
||||
);
|
||||
}
|
||||
|
||||
const rows = [];
|
||||
for (const file of sources) {
|
||||
const r = rel(file);
|
||||
const base = r.split("/").pop();
|
||||
if (!/^[A-Z]/.test(base)) continue;
|
||||
const src = readFileSync(file, "utf8");
|
||||
if (!RENDERS.test(src)) continue;
|
||||
if (INFRA_NAME.test(base) || INFRA_DIR.test("/" + r)) continue;
|
||||
if (CONFIG_FACTORY.test(src)) continue;
|
||||
|
||||
const stem = base.replace(".tsx", "");
|
||||
const tail = r.replace(".tsx", "");
|
||||
const covered =
|
||||
importedNames.has(stem) ||
|
||||
[...importedPaths].some((p) => p.endsWith(tail) || p.endsWith("/" + stem));
|
||||
|
||||
let needs = "props";
|
||||
if (DATA.test(src)) needs = "data";
|
||||
else if (ROUTER.test(src)) needs = "router";
|
||||
else if (needsContext(r)) needs = "context";
|
||||
|
||||
const parts = r.split("/");
|
||||
rows.push({
|
||||
area: parts.slice(0, Math.min(3, parts.length - 1)).join("/"),
|
||||
file: r,
|
||||
covered,
|
||||
needs,
|
||||
loc: src.split("\n").length,
|
||||
});
|
||||
}
|
||||
|
||||
/* ── report ───────────────────────────────────────────────────────────────── */
|
||||
|
||||
const shown = areaFilter
|
||||
? rows.filter((r) => r.file.startsWith(areaFilter))
|
||||
: rows;
|
||||
const todo = shown.filter((r) => !r.covered);
|
||||
|
||||
if (wantTodo || areaFilter) {
|
||||
const order = { props: 0, context: 1, router: 2, data: 3 };
|
||||
for (const r of todo.sort(
|
||||
(a, b) => order[a.needs] - order[b.needs] || a.loc - b.loc,
|
||||
)) {
|
||||
console.log(
|
||||
` ${r.needs.padEnd(8)} ${String(r.loc).padStart(5)} loc ${r.file}`,
|
||||
);
|
||||
}
|
||||
console.log("");
|
||||
}
|
||||
|
||||
const areas = new Map();
|
||||
for (const r of shown) {
|
||||
const a = areas.get(r.area) ?? { total: 0, covered: 0 };
|
||||
a.total += 1;
|
||||
if (r.covered) a.covered += 1;
|
||||
areas.set(r.area, a);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`${"area".padEnd(38)}${"total".padStart(6)}${"covered".padStart(9)}${"%".padStart(6)}`,
|
||||
);
|
||||
for (const [area, a] of [...areas].sort(
|
||||
(x, y) => y[1].total - y[1].covered - (x[1].total - x[1].covered),
|
||||
)) {
|
||||
if (a.total === a.covered) continue;
|
||||
const pct = Math.round((100 * a.covered) / a.total);
|
||||
console.log(
|
||||
`${area.padEnd(38)}${String(a.total).padStart(6)}${String(a.covered).padStart(9)}${String(pct).padStart(5)}%`,
|
||||
);
|
||||
}
|
||||
|
||||
const covered = shown.filter((r) => r.covered).length;
|
||||
const byNeed = todo.reduce(
|
||||
(acc, r) => ((acc[r.needs] = (acc[r.needs] ?? 0) + 1), acc),
|
||||
{},
|
||||
);
|
||||
console.log(
|
||||
`\nsurfaces ${shown.length} covered ${covered} (${Math.round((100 * covered) / shown.length)}%) remaining ${todo.length}`,
|
||||
);
|
||||
console.log(
|
||||
`remaining by what a story needs: ` +
|
||||
Object.entries(byNeed)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([k, v]) => `${k} ${v}`)
|
||||
.join(" "),
|
||||
);
|
||||
@@ -45,6 +45,7 @@ export function OpacityControl({
|
||||
min={10}
|
||||
max={100}
|
||||
label={(val) => `${val}%`}
|
||||
thumbLabel={t("annotation.opacity", "Opacity")}
|
||||
/>
|
||||
</Stack>
|
||||
</Popover.Dropdown>
|
||||
|
||||
@@ -75,6 +75,7 @@ export function PropertiesPopover({
|
||||
min={8}
|
||||
max={32}
|
||||
label={(val) => `${val}pt`}
|
||||
thumbLabel={t("annotation.fontSize", "Font size")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -86,6 +87,7 @@ export function PropertiesPopover({
|
||||
<Slider
|
||||
value={Math.round((obj?.opacity ?? 1) * 100)}
|
||||
onChange={(val) => onUpdate({ opacity: val / 100 })}
|
||||
thumbLabel={t("annotation.opacity", "Opacity")}
|
||||
min={10}
|
||||
max={100}
|
||||
label={(val) => `${val}%`}
|
||||
@@ -144,6 +146,7 @@ export function PropertiesPopover({
|
||||
fillOpacity: newOpacity,
|
||||
});
|
||||
}}
|
||||
thumbLabel={t("annotation.opacity", "Opacity")}
|
||||
min={10}
|
||||
max={100}
|
||||
label={(val) => `${val}%`}
|
||||
@@ -169,6 +172,7 @@ export function PropertiesPopover({
|
||||
min={0}
|
||||
max={12}
|
||||
label={(val) => `${val}pt`}
|
||||
thumbLabel={t("annotation.strokeWidth", "Stroke")}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
|
||||
@@ -49,6 +49,7 @@ export function WidthControl({
|
||||
min={min}
|
||||
max={max}
|
||||
label={(val) => `${val}pt`}
|
||||
thumbLabel={t("annotation.width", "Width")}
|
||||
/>
|
||||
</Stack>
|
||||
</Popover.Dropdown>
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Shared fixtures for the file manager stories.
|
||||
*
|
||||
* These components sit deep in a provider chain — FileManagerContext needs
|
||||
* FileContext for useFileActions/useFileManagement, list rows additionally read
|
||||
* AppConfig — and none of it is part of the shared preview decorators. Rather
|
||||
* than each story rebuilding that tree, they mount the real providers here over
|
||||
* static data, so what a story exercises is the component and not a stub.
|
||||
*/
|
||||
import type { ReactElement } from "react";
|
||||
import { AppConfigProvider } from "@app/contexts/AppConfigContext";
|
||||
import { FileContextProvider } from "@app/contexts/FileContext";
|
||||
import { FileManagerProvider } from "@app/contexts/FileManagerContext";
|
||||
import { PreferencesProvider } from "@app/contexts/PreferencesContext";
|
||||
import type { StirlingFileStub } from "@app/types/fileContext";
|
||||
import type { FileId } from "@app/types/file";
|
||||
|
||||
/** A grey rectangle, so thumbnail lookups short-circuit instead of reading
|
||||
* file bytes out of IndexedDB. */
|
||||
const THUMBNAIL =
|
||||
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='160'%3E%3Crect width='120' height='160' fill='%23e9ecef'/%3E%3C/svg%3E";
|
||||
|
||||
/** Fixed so stories render identically on every run. */
|
||||
const LAST_MODIFIED = Date.parse("2026-03-14T09:30:00Z");
|
||||
|
||||
export function makeStub(
|
||||
id: string,
|
||||
name: string,
|
||||
overrides: Partial<StirlingFileStub> = {},
|
||||
): StirlingFileStub {
|
||||
return {
|
||||
id: id as FileId,
|
||||
name,
|
||||
type: "application/pdf",
|
||||
size: 2_400_000,
|
||||
lastModified: LAST_MODIFIED,
|
||||
isLeaf: true,
|
||||
originalFileId: id,
|
||||
versionNumber: 1,
|
||||
thumbnailUrl: THUMBNAIL,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export const mockFile = makeStub("story-file-1", "quarterly-report.pdf");
|
||||
|
||||
/** Storage off keeps the row's upload and share affordances out of the way;
|
||||
* stories that want them pass their own config. */
|
||||
const BASE_CONFIG = {
|
||||
storageEnabled: false,
|
||||
storageSharingEnabled: false,
|
||||
storageShareLinksEnabled: false,
|
||||
frontendUrl: "https://stirling.example",
|
||||
};
|
||||
|
||||
interface FixtureOptions {
|
||||
recentFiles?: StirlingFileStub[];
|
||||
activeFileIds?: FileId[];
|
||||
isLoading?: boolean;
|
||||
config?: Partial<typeof BASE_CONFIG>;
|
||||
}
|
||||
|
||||
export function withFileManager({
|
||||
recentFiles = [mockFile],
|
||||
activeFileIds = [],
|
||||
isLoading = false,
|
||||
config,
|
||||
}: FixtureOptions = {}) {
|
||||
return (Story: () => ReactElement) => (
|
||||
<AppConfigProvider
|
||||
initialConfig={{ ...BASE_CONFIG, ...config } as never}
|
||||
bootstrapMode="non-blocking"
|
||||
autoFetch={false}
|
||||
>
|
||||
{/* The empty state's wordmark resolves a logo variant through
|
||||
PreferencesContext, which the preview does not mount. */}
|
||||
<PreferencesProvider>
|
||||
<FileContextProvider>
|
||||
<FileManagerProvider
|
||||
recentFiles={recentFiles}
|
||||
onRecentFilesSelected={() => {}}
|
||||
onNewFilesSelect={() => {}}
|
||||
onClose={() => {}}
|
||||
isFileSupported={() => true}
|
||||
isOpen
|
||||
onFileRemove={() => {}}
|
||||
modalHeight="600px"
|
||||
refreshRecentFiles={async () => {}}
|
||||
isLoading={isLoading}
|
||||
activeFileIds={activeFileIds}
|
||||
>
|
||||
<Story />
|
||||
</FileManagerProvider>
|
||||
</FileContextProvider>
|
||||
</PreferencesProvider>
|
||||
</AppConfigProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Shared context slices for the tool-panel stories.
|
||||
*
|
||||
* These components sit under providers that reach most of the app — the tool
|
||||
* workflow stands up the registry and navigation, the workbench bar derives its
|
||||
* buttons from the whole workbench. Each component here reads only a handful of
|
||||
* fields, so the slices below supply those instead. What a story mounts is then
|
||||
* an honest record of what its component actually depends on.
|
||||
*/
|
||||
import type { ReactElement } from "react";
|
||||
import { AppConfigProvider } from "@app/contexts/AppConfigContext";
|
||||
import { FileContextProvider } from "@app/contexts/FileContext";
|
||||
import {
|
||||
NavigationStateContext,
|
||||
type NavigationContextStateValue,
|
||||
} from "@app/contexts/NavigationContext";
|
||||
import {
|
||||
ToolWorkflowContext,
|
||||
ToolWorkflowDataContext,
|
||||
ToolWorkflowActionsContext,
|
||||
type ToolWorkflowContextValue,
|
||||
type ToolWorkflowDataValue,
|
||||
type ToolWorkflowActionsValue,
|
||||
} from "@app/contexts/ToolWorkflowContext";
|
||||
import {
|
||||
HotkeyContext,
|
||||
type HotkeyContextValue,
|
||||
} from "@app/contexts/HotkeyContext";
|
||||
import {
|
||||
FilesModalContext,
|
||||
type FilesModalContextType,
|
||||
} from "@app/contexts/FilesModalContext";
|
||||
import {
|
||||
ViewerContext,
|
||||
type ViewerContextType,
|
||||
} from "@app/contexts/ViewerContext";
|
||||
|
||||
export interface ToolContextOptions {
|
||||
/** Which workbench is active; several components render only in one. */
|
||||
workbench?: string;
|
||||
/** Favourited tool ids, for anything drawing a star. */
|
||||
favourites?: string[];
|
||||
/** Index the viewer is showing, for scope-aware copy. */
|
||||
activeFileIndex?: number;
|
||||
/** Extra viewer fields for components that reach further into it. */
|
||||
viewer?: Partial<Record<string, unknown>>;
|
||||
/** Extra workflow fields — search state, panel view, the open tool. */
|
||||
workflow?: Partial<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
export function withToolContexts({
|
||||
workbench = "viewer",
|
||||
favourites = [],
|
||||
activeFileIndex = 0,
|
||||
viewer = {},
|
||||
workflow = {},
|
||||
}: ToolContextOptions = {}) {
|
||||
return (Story: () => ReactElement): ReactElement => (
|
||||
<AppConfigProvider
|
||||
initialConfig={{ premiumEnabled: true } as never}
|
||||
bootstrapMode="non-blocking"
|
||||
autoFetch={false}
|
||||
>
|
||||
<HotkeyContext.Provider value={{ hotkeys: {} } as HotkeyContextValue}>
|
||||
<NavigationStateContext.Provider
|
||||
value={{ workbench } as unknown as NavigationContextStateValue}
|
||||
>
|
||||
<ToolWorkflowContext.Provider
|
||||
value={
|
||||
{
|
||||
getSelectedTool: () => null,
|
||||
toolPanelMode: "normal",
|
||||
leftPanelView: "tools",
|
||||
readerMode: false,
|
||||
...workflow,
|
||||
} as unknown as ToolWorkflowContextValue
|
||||
}
|
||||
>
|
||||
<ToolWorkflowDataContext.Provider
|
||||
value={
|
||||
{
|
||||
isFavorite: (id: string) => favourites.includes(id),
|
||||
toolAvailability: {},
|
||||
toolRegistry: {},
|
||||
favoriteTools: favourites,
|
||||
} as unknown as ToolWorkflowDataValue
|
||||
}
|
||||
>
|
||||
<ToolWorkflowActionsContext.Provider
|
||||
value={
|
||||
{
|
||||
toggleFavorite: () => {},
|
||||
handleToolSelect: () => {},
|
||||
} as unknown as ToolWorkflowActionsValue
|
||||
}
|
||||
>
|
||||
<ViewerContext.Provider
|
||||
value={
|
||||
{
|
||||
activeFileIndex,
|
||||
...viewer,
|
||||
} as unknown as ViewerContextType
|
||||
}
|
||||
>
|
||||
<FilesModalContext.Provider
|
||||
value={
|
||||
{
|
||||
openFilesModal: () => {},
|
||||
onFileUpload: () => {},
|
||||
} as unknown as FilesModalContextType
|
||||
}
|
||||
>
|
||||
{/* Real FileContext: the file list drives scope-aware copy,
|
||||
and it stands up standalone with no files. */}
|
||||
<FileContextProvider>
|
||||
<Story />
|
||||
</FileContextProvider>
|
||||
</FilesModalContext.Provider>
|
||||
</ViewerContext.Provider>
|
||||
</ToolWorkflowActionsContext.Provider>
|
||||
</ToolWorkflowDataContext.Provider>
|
||||
</ToolWorkflowContext.Provider>
|
||||
</NavigationStateContext.Provider>
|
||||
</HotkeyContext.Provider>
|
||||
</AppConfigProvider>
|
||||
);
|
||||
}
|
||||
@@ -334,6 +334,7 @@ export function PdfViewerToolbar({
|
||||
max={500}
|
||||
step={5}
|
||||
onChange={(val) => zoomActions.setZoomLevel?.(val / 100)}
|
||||
thumbLabel={t("viewer.zoomLevel", "Zoom level")}
|
||||
size="xs"
|
||||
styles={{
|
||||
root: { minWidth: "6rem", width: "6rem", flexShrink: 0 },
|
||||
|
||||
@@ -451,6 +451,7 @@ export function useViewerWorkbenchBarButtons(
|
||||
<Slider
|
||||
value={speechRate}
|
||||
onChange={handleSpeechRateChange}
|
||||
thumbLabel={readAloudSpeedLabel}
|
||||
min={0.5}
|
||||
max={2}
|
||||
step={0.1}
|
||||
|
||||
@@ -29,7 +29,7 @@ import { openFilesFromDisk } from "@app/services/openFilesFromDisk";
|
||||
export { pendingFilePathMappings } from "@app/services/pendingFilePathMappings";
|
||||
|
||||
// Type for the context value - now contains everything directly
|
||||
interface FileManagerContextValue {
|
||||
export interface FileManagerContextValue {
|
||||
// State
|
||||
activeSource: "recent" | "local" | "drive";
|
||||
storageFilter: "all" | "local" | "sharedWithMe" | "sharedByMe";
|
||||
@@ -80,8 +80,11 @@ interface FileManagerContextValue {
|
||||
modalHeight: string;
|
||||
}
|
||||
|
||||
// Create the context
|
||||
const FileManagerContext = createContext<FileManagerContextValue | null>(null);
|
||||
// Create the context. Exported so a test or story can mount a component
|
||||
// against a slice of the value instead of the whole provider chain.
|
||||
export const FileManagerContext = createContext<FileManagerContextValue | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
// Provider component props
|
||||
interface FileManagerProviderProps {
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
readResponseHeader,
|
||||
} from "@app/services/shareBundleUtils";
|
||||
|
||||
interface FilesModalContextType {
|
||||
export interface FilesModalContextType {
|
||||
isFilesModalOpen: boolean;
|
||||
openFilesModal: (options?: {
|
||||
insertAfterPage?: number;
|
||||
@@ -41,7 +41,10 @@ interface FilesModalContextType {
|
||||
setOnModalClose: (callback: () => void) => void;
|
||||
}
|
||||
|
||||
const FilesModalContext = createContext<FilesModalContextType | null>(null);
|
||||
// Exported so a test or story can mount a component against a slice of the
|
||||
// value: the provider itself pulls in FileContext and NavigationContext.
|
||||
export const FilesModalContext =
|
||||
createContext<FilesModalContextType | null>(null);
|
||||
|
||||
export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||
children,
|
||||
|
||||
@@ -22,7 +22,7 @@ import { ToolCategoryId, ToolRegistryEntry } from "@app/data/toolsTaxonomy";
|
||||
|
||||
type Bindings = Partial<Record<ToolId, HotkeyBinding>>;
|
||||
|
||||
interface HotkeyContextValue {
|
||||
export interface HotkeyContextValue {
|
||||
hotkeys: Bindings;
|
||||
defaults: Bindings;
|
||||
isMac: boolean;
|
||||
@@ -38,7 +38,12 @@ interface HotkeyContextValue {
|
||||
getDisplayParts: (binding: HotkeyBinding | null | undefined) => string[];
|
||||
}
|
||||
|
||||
const HotkeyContext = createContext<HotkeyContextValue | undefined>(undefined);
|
||||
// Exported so a component that only reads a slice of this (HotkeyDisplay wants
|
||||
// getDisplayParts) can be mounted without HotkeyProvider, which pulls in the
|
||||
// whole tool-workflow chain behind it.
|
||||
export const HotkeyContext = createContext<HotkeyContextValue | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
const STORAGE_KEY = "stirlingpdf.hotkeys";
|
||||
|
||||
|
||||
@@ -128,8 +128,11 @@ export interface NavigationContextActionsValue {
|
||||
actions: NavigationContextActions;
|
||||
}
|
||||
|
||||
// Create contexts
|
||||
const NavigationStateContext = createContext<
|
||||
// Create contexts. The state context is exported so a test or story can mount a
|
||||
// component against a slice of it: the provider itself reaches the tool
|
||||
// registry, which is far more than a component reading one navigation field
|
||||
// needs standing up.
|
||||
export const NavigationStateContext = createContext<
|
||||
NavigationContextStateValue | undefined
|
||||
>(undefined);
|
||||
const NavigationActionsContext = createContext<
|
||||
|
||||
@@ -58,7 +58,7 @@ export interface CustomWorkbenchViewInstance extends CustomWorkbenchViewRegistra
|
||||
data: any;
|
||||
}
|
||||
|
||||
interface ToolWorkflowContextValue extends ToolWorkflowState {
|
||||
export interface ToolWorkflowContextValue extends ToolWorkflowState {
|
||||
// Tool management (from hook)
|
||||
selectedToolKey: ToolId | null;
|
||||
selectedTool: ToolRegistryEntry | null;
|
||||
@@ -116,7 +116,10 @@ const __GLOBAL_CONTEXT_KEY__ = "__ToolWorkflowContext__";
|
||||
const existingContext = (globalThis as any)[__GLOBAL_CONTEXT_KEY__] as
|
||||
| React.Context<ToolWorkflowContextValue | undefined>
|
||||
| undefined;
|
||||
const ToolWorkflowContext =
|
||||
// Exported so a test or story can mount a component against a slice of the
|
||||
// value. The provider itself stands up the whole tool registry and navigation
|
||||
// chain, which is far more than a component reading a few fields needs.
|
||||
export const ToolWorkflowContext =
|
||||
existingContext ??
|
||||
createContext<ToolWorkflowContextValue | undefined>(undefined);
|
||||
if (!existingContext) {
|
||||
@@ -156,10 +159,14 @@ export interface ToolWorkflowDataValue {
|
||||
isFavorite: (toolId: ToolId) => boolean;
|
||||
}
|
||||
|
||||
const ToolWorkflowActionsContext = createContext<
|
||||
// Exported alongside ToolWorkflowContext so a story can supply the callbacks a
|
||||
// component reaches for without standing up the provider.
|
||||
export const ToolWorkflowActionsContext = createContext<
|
||||
ToolWorkflowActionsValue | undefined
|
||||
>(undefined);
|
||||
const ToolWorkflowDataContext = createContext<
|
||||
// Exported alongside the other two so a story can supply the registry and
|
||||
// favourites a component reads without standing up the provider.
|
||||
export const ToolWorkflowDataContext = createContext<
|
||||
ToolWorkflowDataValue | undefined
|
||||
>(undefined);
|
||||
|
||||
|
||||
@@ -21,7 +21,9 @@ interface WorkbenchBarContextValue {
|
||||
clear: () => void;
|
||||
}
|
||||
|
||||
const WorkbenchBarContext = createContext<WorkbenchBarContextValue | undefined>(
|
||||
// Exported so a story can supply the bar's buttons without the provider,
|
||||
// which derives them from the whole workbench.
|
||||
export const WorkbenchBarContext = createContext<WorkbenchBarContextValue | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
|
||||
@@ -374,3 +374,14 @@ html[data-app-theme="custom"][data-accent="default"][data-mantine-color-scheme="
|
||||
--c-border: var(--p-c-28282d);
|
||||
--c-border-subtle: var(--p-zinc-700);
|
||||
}
|
||||
|
||||
/* ── MANTINE MUTED TEXT ───────────────────────────────────────────────────── */
|
||||
/* Mantine's stock "dimmed" is #868e96, which measures ~3:1 against the app's
|
||||
surfaces — short of the 4.5:1 that body-size text needs. Pointing it at the
|
||||
semantic muted token fixes every `c="dimmed"` at once, and follows the theme
|
||||
rather than staying a fixed grey that only ever suited light mode. */
|
||||
/* Doubled selector: Mantine declares this on :root too, and its stylesheet
|
||||
loads later, so a single :root here would lose on source order. */
|
||||
:root:root {
|
||||
--mantine-color-dimmed: var(--c-text-muted);
|
||||
}
|
||||
|
||||
@@ -572,6 +572,7 @@ export function AnnotationPanel(props: AnnotationPanelProps) {
|
||||
min={1}
|
||||
max={12}
|
||||
value={inkWidth}
|
||||
thumbLabel={t("annotation.strokeWidth", "Width")}
|
||||
onChange={setInkWidth}
|
||||
/>
|
||||
</Box>
|
||||
@@ -587,6 +588,7 @@ export function AnnotationPanel(props: AnnotationPanelProps) {
|
||||
min={10}
|
||||
max={100}
|
||||
value={highlightOpacity}
|
||||
thumbLabel={t("annotation.opacity", "Opacity")}
|
||||
onChange={setHighlightOpacity}
|
||||
/>
|
||||
</Box>
|
||||
@@ -601,6 +603,7 @@ export function AnnotationPanel(props: AnnotationPanelProps) {
|
||||
min={10}
|
||||
max={100}
|
||||
value={underlineOpacity}
|
||||
thumbLabel={t("annotation.opacity", "Opacity")}
|
||||
onChange={setUnderlineOpacity}
|
||||
/>
|
||||
</Box>
|
||||
@@ -615,6 +618,7 @@ export function AnnotationPanel(props: AnnotationPanelProps) {
|
||||
min={1}
|
||||
max={20}
|
||||
value={freehandHighlighterWidth}
|
||||
thumbLabel={t("annotation.strokeWidth", "Width")}
|
||||
onChange={setFreehandHighlighterWidth}
|
||||
/>
|
||||
</Box>
|
||||
@@ -630,6 +634,7 @@ export function AnnotationPanel(props: AnnotationPanelProps) {
|
||||
min={8}
|
||||
max={32}
|
||||
value={textSize}
|
||||
thumbLabel={t("annotation.fontSize", "Font size")}
|
||||
onChange={setTextSize}
|
||||
/>
|
||||
</Box>
|
||||
@@ -785,6 +790,7 @@ export function AnnotationPanel(props: AnnotationPanelProps) {
|
||||
min={10}
|
||||
max={100}
|
||||
value={shapeOpacity}
|
||||
thumbLabel={t("annotation.opacity", "Opacity")}
|
||||
onChange={(value) => {
|
||||
setShapeOpacity(value);
|
||||
setShapeStrokeOpacity(value);
|
||||
@@ -802,6 +808,7 @@ export function AnnotationPanel(props: AnnotationPanelProps) {
|
||||
min={1}
|
||||
max={12}
|
||||
value={shapeThickness}
|
||||
thumbLabel={t("annotation.strokeWidth", "Width")}
|
||||
onChange={setShapeThickness}
|
||||
/>
|
||||
</>
|
||||
@@ -815,6 +822,7 @@ export function AnnotationPanel(props: AnnotationPanelProps) {
|
||||
min={0}
|
||||
max={12}
|
||||
value={shapeThickness}
|
||||
thumbLabel={t("annotation.strokeWidth", "Stroke")}
|
||||
onChange={setShapeThickness}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Form-fill context for the formFill stories.
|
||||
*
|
||||
* Every form-fill component reads its fields and values from FormFillProvider,
|
||||
* and the provider only ever gets them by asking a data provider to parse a real
|
||||
* PDF. Both shipped providers need either the pdfium WASM module or the backend,
|
||||
* neither of which exists in a story — so these helpers hand the provider a stub
|
||||
* that returns fixed fields, and drive it the way the app does: fetch on mount,
|
||||
* then type into the form.
|
||||
*/
|
||||
import { useEffect, useRef, type ReactElement, type ReactNode } from "react";
|
||||
import {
|
||||
FormFillProvider,
|
||||
useFormFill,
|
||||
} from "@app/tools/formFill/FormFillContext";
|
||||
import type { IFormDataProvider } from "@app/tools/formFill/providers/types";
|
||||
import type { FormField } from "@app/tools/formFill/types";
|
||||
|
||||
/** Stands in for the open document; the stub provider never reads its bytes. */
|
||||
export const STORY_PDF = new Blob(["%PDF-1.7"], { type: "application/pdf" });
|
||||
|
||||
export function field(
|
||||
overrides: Partial<FormField> & { name: string },
|
||||
): FormField {
|
||||
return {
|
||||
label: overrides.name,
|
||||
type: "text",
|
||||
value: "",
|
||||
options: null,
|
||||
displayOptions: null,
|
||||
required: false,
|
||||
readOnly: false,
|
||||
multiSelect: false,
|
||||
multiline: false,
|
||||
tooltip: null,
|
||||
widgets: [{ pageIndex: 0, x: 72, y: 120, width: 220, height: 22 }],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** A small cross-section of field types, as a filled-in form would carry. */
|
||||
export const SAMPLE_FIELDS: FormField[] = [
|
||||
field({ name: "fullName", label: "Full name", required: true }),
|
||||
field({
|
||||
name: "address",
|
||||
label: "Address",
|
||||
multiline: true,
|
||||
tooltip: "Include the postcode",
|
||||
}),
|
||||
field({
|
||||
name: "agreeToTerms",
|
||||
label: "I agree to the terms",
|
||||
type: "checkbox",
|
||||
}),
|
||||
field({
|
||||
name: "country",
|
||||
label: "Country",
|
||||
type: "combobox",
|
||||
options: ["uk", "fr", "de"],
|
||||
displayOptions: ["United Kingdom", "France", "Germany"],
|
||||
}),
|
||||
field({
|
||||
name: "reference",
|
||||
label: "Reference number",
|
||||
readOnly: true,
|
||||
value: "INV-20418",
|
||||
}),
|
||||
field({
|
||||
name: "signature",
|
||||
label: "Signature",
|
||||
type: "signature",
|
||||
widgets: [{ pageIndex: 1, x: 72, y: 480, width: 180, height: 60 }],
|
||||
}),
|
||||
];
|
||||
|
||||
export interface FormFillOptions {
|
||||
/** Fields the stub provider reports for the document. */
|
||||
fields?: FormField[];
|
||||
/** Hold the fetch open, so the form stays in its loading state. */
|
||||
pending?: boolean;
|
||||
/** Values applied after load — this is what marks the form dirty. */
|
||||
filled?: Record<string, string>;
|
||||
/** Field to focus, as clicking its widget on the page would. */
|
||||
activeField?: string;
|
||||
}
|
||||
|
||||
function stubProvider({
|
||||
fields = SAMPLE_FIELDS,
|
||||
pending = false,
|
||||
}: FormFillOptions): IFormDataProvider {
|
||||
return {
|
||||
name: "pdf-lib",
|
||||
fetchFields: () =>
|
||||
pending ? new Promise<FormField[]>(() => {}) : Promise.resolve(fields),
|
||||
fillForm: async () => STORY_PDF,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Drives the provider once on mount. The fetch is what populates the fields and
|
||||
* seeds the value store; anything applied afterwards counts as user input.
|
||||
*/
|
||||
function FormFillLoader({
|
||||
filled,
|
||||
activeField,
|
||||
children,
|
||||
}: FormFillOptions & { children: ReactNode }) {
|
||||
const { fetchFields, setValue, setActiveField } = useFormFill();
|
||||
const startedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (startedRef.current) return;
|
||||
startedRef.current = true;
|
||||
void fetchFields(STORY_PDF, "story-document").then(() => {
|
||||
Object.entries(filled ?? {}).forEach(([name, value]) =>
|
||||
setValue(name, value),
|
||||
);
|
||||
if (activeField) setActiveField(activeField);
|
||||
});
|
||||
}, [fetchFields, setValue, setActiveField, filled, activeField]);
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
export function withFormFill(options: FormFillOptions = {}) {
|
||||
return (Story: () => ReactElement): ReactElement => (
|
||||
<FormFillProvider provider={stubProvider(options)}>
|
||||
<FormFillLoader {...options}>
|
||||
<Story />
|
||||
</FormFillLoader>
|
||||
</FormFillProvider>
|
||||
);
|
||||
}
|
||||
@@ -24,10 +24,14 @@
|
||||
--_tert-tint: var(--c-hover);
|
||||
}
|
||||
/* Danger is pinned to a fixed deep red (not the theme-lightened coral), so the
|
||||
fill and the outline/text are the SAME red in both light and dark. */
|
||||
fill and the outline/text are the SAME red in both light and dark.
|
||||
|
||||
The fill is red-600 rather than red-500 because white sits on it: red-500
|
||||
gives 3.76:1, short of the 4.5:1 body text needs, while red-600 gives 4.85:1
|
||||
and holds in either theme. */
|
||||
.sui-acc-danger {
|
||||
--_solid: var(--p-red-500);
|
||||
--_solid-hover: var(--p-red-600);
|
||||
--_solid: var(--p-red-600);
|
||||
--_solid-hover: color-mix(in srgb, var(--p-red-600) 85%, var(--p-black));
|
||||
--_on: #ffffff;
|
||||
--_text: var(--p-red-500);
|
||||
--_bd: color-mix(in srgb, var(--p-red-500) 45%, transparent);
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* The frame every desktop setup-wizard screen sits in. It contributes the
|
||||
* branding and centring; the screen itself is passed as children.
|
||||
*/
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { DesktopAuthLayout } from "@app/components/SetupWizard/DesktopAuthLayout";
|
||||
import { PreferencesProvider } from "@app/contexts/PreferencesContext";
|
||||
|
||||
const meta: Meta<typeof DesktopAuthLayout> = {
|
||||
title: "Desktop/SetupWizard/DesktopAuthLayout",
|
||||
component: DesktopAuthLayout,
|
||||
parameters: { layout: "fullscreen" },
|
||||
// The shell's wordmark resolves a logo variant through PreferencesContext.
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<PreferencesProvider>
|
||||
<Story />
|
||||
</PreferencesProvider>
|
||||
),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof DesktopAuthLayout>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: { children: <p>Sign-in form goes here.</p> },
|
||||
};
|
||||
|
||||
/** Taller content, where the frame has to scroll rather than clip. */
|
||||
export const TallContent: Story = {
|
||||
args: {
|
||||
children: (
|
||||
<div style={{ display: "grid", gap: "1rem" }}>
|
||||
{Array.from({ length: 12 }, (_, i) => (
|
||||
<p key={i}>Setup step detail line {i + 1}</p>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* The escape hatch at the bottom of the desktop sign-in screen, for people
|
||||
* connecting to their own server rather than a Stirling account.
|
||||
*/
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { SelfHostedLink } from "@app/components/SetupWizard/SelfHostedLink";
|
||||
|
||||
const meta: Meta<typeof SelfHostedLink> = {
|
||||
title: "Desktop/SetupWizard/SelfHostedLink",
|
||||
component: SelfHostedLink,
|
||||
parameters: { layout: "centered" },
|
||||
args: { onClick: () => {} },
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof SelfHostedLink>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
/** Disabled while the wizard is mid-request. */
|
||||
export const Disabled: Story = { args: { disabled: true } };
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* The save-state dot on a desktop file thumbnail. Three states, decided from
|
||||
* the file stub rather than a prop: never written to disk, written but with
|
||||
* unsaved edits, and clean.
|
||||
*/
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { FileEditorStatusDot } from "@app/components/fileEditor/FileEditorStatusDot";
|
||||
import type { StirlingFileStub } from "@app/types/fileContext";
|
||||
import type { FileId } from "@app/types/file";
|
||||
|
||||
function stub(overrides: Partial<StirlingFileStub>): StirlingFileStub {
|
||||
return {
|
||||
id: "story-file" as FileId,
|
||||
name: "report.pdf",
|
||||
type: "application/pdf",
|
||||
size: 120_000,
|
||||
lastModified: Date.parse("2026-03-14T09:30:00Z"),
|
||||
isLeaf: true,
|
||||
originalFileId: "story-file",
|
||||
versionNumber: 1,
|
||||
...overrides,
|
||||
} as StirlingFileStub;
|
||||
}
|
||||
|
||||
const meta: Meta<typeof FileEditorStatusDot> = {
|
||||
title: "Desktop/FileEditorStatusDot",
|
||||
component: FileEditorStatusDot,
|
||||
parameters: { layout: "centered" },
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof FileEditorStatusDot>;
|
||||
|
||||
/** Held in memory only — nothing has been written to disk yet. */
|
||||
export const NotSaved: Story = {
|
||||
args: { file: stub({ localFilePath: undefined }) },
|
||||
};
|
||||
|
||||
/** On disk, but edited since. */
|
||||
export const UnsavedChanges: Story = {
|
||||
args: { file: stub({ localFilePath: "/tmp/report.pdf", isDirty: true }) },
|
||||
};
|
||||
|
||||
export const Saved: Story = {
|
||||
args: { file: stub({ localFilePath: "/tmp/report.pdf", isDirty: false }) },
|
||||
};
|
||||
@@ -25,9 +25,12 @@ export function FileEditorStatusDot({ file }: FileEditorStatusDotProps) {
|
||||
return (
|
||||
<div className={styles.thumbBadgesRight}>
|
||||
<Tooltip label={label}>
|
||||
{/* A bare span cannot carry aria-label; without a role the save state
|
||||
is conveyed by colour alone and announced as nothing at all. */}
|
||||
<span
|
||||
className={styles.statusDot}
|
||||
style={{ backgroundColor: color }}
|
||||
role="img"
|
||||
aria-label={label}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* A div dressed as a disabled button. Mantine's `disabled` kills pointer events
|
||||
* outright, which also kills the tooltip explaining *why* the control is
|
||||
* unavailable — so this renders the disabled look by hand and keeps hover.
|
||||
*
|
||||
* Desktop-layer stories resolve `@app/*` against desktop → cloud → proprietary
|
||||
* → core, matching the desktop build rather than the editor's order.
|
||||
*/
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { DisabledButtonWithTooltip } from "@app/components/shared/DisabledButtonWithTooltip";
|
||||
|
||||
const meta: Meta<typeof DisabledButtonWithTooltip> = {
|
||||
title: "Desktop/DisabledButtonWithTooltip",
|
||||
component: DisabledButtonWithTooltip,
|
||||
parameters: { layout: "padded" },
|
||||
args: {
|
||||
tooltip: "Sign in to use this tool",
|
||||
children: "Convert to PDF/A",
|
||||
},
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof DisabledButtonWithTooltip>;
|
||||
|
||||
/** The tooltip only appears on hover, so the resting state is what is captured. */
|
||||
export const Default: Story = {};
|
||||
|
||||
export const LongLabel: Story = {
|
||||
args: { children: "Convert this document to an archival PDF/A-3b file" },
|
||||
};
|
||||
|
||||
/** A long reason wraps inside the tooltip rather than widening it. */
|
||||
export const LongTooltip: Story = {
|
||||
args: {
|
||||
tooltip:
|
||||
"This tool needs a desktop licence and an active connection to the cloud backend.",
|
||||
},
|
||||
};
|
||||
|
||||
/** In a narrow column the control still fills its container. */
|
||||
export const Narrow: Story = {
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div style={{ width: 200 }}>
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
@@ -20,25 +20,39 @@ export function DisabledButtonWithTooltip({
|
||||
className,
|
||||
style,
|
||||
}: DisabledButtonWithTooltipProps) {
|
||||
const [hovered, setHovered] = React.useState(false);
|
||||
const [shown, setShown] = React.useState(false);
|
||||
const tooltipId = React.useId();
|
||||
return (
|
||||
<div
|
||||
className="relative w-full"
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
onMouseEnter={() => setShown(true)}
|
||||
onMouseLeave={() => setShown(false)}
|
||||
>
|
||||
{/* The point of this control is to look disabled while still explaining
|
||||
why. That explanation has to reach a keyboard as well as a pointer, so
|
||||
the element stays focusable and announces itself as a disabled button
|
||||
described by its own tooltip. */}
|
||||
<div
|
||||
className={`locked-button${className ? ` ${className}` : ""}`}
|
||||
style={style}
|
||||
role="button"
|
||||
aria-disabled="true"
|
||||
aria-describedby={tooltipId}
|
||||
tabIndex={0}
|
||||
onFocus={() => setShown(true)}
|
||||
onBlur={() => setShown(false)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{hovered && (
|
||||
<div className="locked-button-tooltip">
|
||||
{tooltip}
|
||||
<div className="locked-button-tooltip-arrow" />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
id={tooltipId}
|
||||
role="tooltip"
|
||||
className="locked-button-tooltip"
|
||||
style={shown ? undefined : { display: "none" }}
|
||||
>
|
||||
{tooltip}
|
||||
<div className="locked-button-tooltip-arrow" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import type { Decorator, Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { AuthContext } from "@app/auth/context";
|
||||
import type { AuthContextValue, AuthSession } from "@app/auth/types";
|
||||
import { RequireAdmin } from "@app/auth/guards/RequireAdmin";
|
||||
|
||||
/**
|
||||
* The admin gate around the settings and processor route trees. Three fields
|
||||
* of the auth context decide what it renders: while `loading` it shows the
|
||||
* `loading` slot, with no session it shows `fallback`, and for a signed-in
|
||||
* user who is not an admin it shows `forbidden` while calling `onForbidden`
|
||||
* so the route can redirect. Only an authenticated admin sees the children.
|
||||
*
|
||||
* The guard reads three fields, so the stories supply a slice of the auth
|
||||
* context rather than mounting a real provider, and each slot renders as a
|
||||
* labelled panel so the branch taken is visible.
|
||||
*/
|
||||
|
||||
/** Auth context slice; `session`, `loading` and `isAdmin` steer this guard. */
|
||||
function authValue(
|
||||
overrides: Partial<AuthContextValue> = {},
|
||||
): AuthContextValue {
|
||||
return {
|
||||
session: null,
|
||||
user: null,
|
||||
displayName: null,
|
||||
isAnonymous: false,
|
||||
isAdmin: false,
|
||||
portalAccess: false,
|
||||
role: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
signOut: async () => {},
|
||||
refreshSession: async () => {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const activeSession: AuthSession = {
|
||||
user: { id: "1", email: "ada@example.com", username: "ada", role: "USER" },
|
||||
access_token: "storybook-token",
|
||||
expires_in: 3600,
|
||||
};
|
||||
|
||||
/** Labelled stand-in for whichever slot the guard chose to render. */
|
||||
function Panel({ label }: { label: string }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: "1.5rem",
|
||||
borderRadius: "0.75rem",
|
||||
border: "1px solid var(--c-border)",
|
||||
background: "var(--c-surface)",
|
||||
color: "var(--c-text)",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function withAuth(value: AuthContextValue): Decorator {
|
||||
return (Story) => (
|
||||
<AuthContext.Provider value={value}>
|
||||
<Story />
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
const meta: Meta<typeof RequireAdmin> = {
|
||||
title: "Auth/Guards/Require Admin",
|
||||
component: RequireAdmin,
|
||||
parameters: { layout: "centered" },
|
||||
args: {
|
||||
children: <Panel label="Admin content" />,
|
||||
fallback: <Panel label="Login screen (fallback)" />,
|
||||
forbidden: <Panel label="Redirecting away…" />,
|
||||
loading: <Panel label="Resolving session…" />,
|
||||
onForbidden: () => {},
|
||||
},
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof RequireAdmin>;
|
||||
|
||||
/** An authenticated admin: the guarded tree renders. */
|
||||
export const Admin: Story = {
|
||||
decorators: [withAuth(authValue({ session: activeSession, isAdmin: true }))],
|
||||
};
|
||||
|
||||
/**
|
||||
* Signed in without the admin role. `onForbidden` fires so the route can send
|
||||
* the user back to the editor; the forbidden slot covers the gap until it does.
|
||||
*/
|
||||
export const NotAdmin: Story = {
|
||||
decorators: [withAuth(authValue({ session: activeSession }))],
|
||||
};
|
||||
|
||||
/** No session at all: the login screen takes over, no redirect is triggered. */
|
||||
export const SignedOut: Story = {
|
||||
decorators: [withAuth(authValue())],
|
||||
};
|
||||
|
||||
/**
|
||||
* Session still resolving. The redirect is deliberately held back here — a
|
||||
* half-loaded session must not be mistaken for a non-admin one.
|
||||
*/
|
||||
export const Loading: Story = {
|
||||
decorators: [withAuth(authValue({ loading: true }))],
|
||||
};
|
||||
@@ -0,0 +1,104 @@
|
||||
import type { Decorator, Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { AuthContext } from "@app/auth/context";
|
||||
import type { AuthContextValue, AuthSession } from "@app/auth/types";
|
||||
import { RequireAuth } from "@app/auth/guards/RequireAuth";
|
||||
|
||||
/**
|
||||
* The session gate wrapped around protected route trees. It renders one of
|
||||
* three slots depending purely on the auth context: `loading` while the
|
||||
* session is still resolving, `fallback` when there is no session, and its
|
||||
* children once a session exists.
|
||||
*
|
||||
* The guard reads only `session` and `loading`, so the stories hand it a slice
|
||||
* of the auth context instead of mounting a real provider. Each slot is a
|
||||
* labelled panel so it is obvious which branch was taken.
|
||||
*/
|
||||
|
||||
/** Auth context slice; only `session` and `loading` steer this guard. */
|
||||
function authValue(
|
||||
overrides: Partial<AuthContextValue> = {},
|
||||
): AuthContextValue {
|
||||
return {
|
||||
session: null,
|
||||
user: null,
|
||||
displayName: null,
|
||||
isAnonymous: false,
|
||||
isAdmin: false,
|
||||
portalAccess: false,
|
||||
role: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
signOut: async () => {},
|
||||
refreshSession: async () => {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const activeSession: AuthSession = {
|
||||
user: { id: "1", email: "ada@example.com", username: "ada", role: "USER" },
|
||||
access_token: "storybook-token",
|
||||
expires_in: 3600,
|
||||
};
|
||||
|
||||
/** Labelled stand-in for whichever slot the guard chose to render. */
|
||||
function Panel({ label }: { label: string }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: "1.5rem",
|
||||
borderRadius: "0.75rem",
|
||||
border: "1px solid var(--c-border)",
|
||||
background: "var(--c-surface)",
|
||||
color: "var(--c-text)",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function withAuth(value: AuthContextValue): Decorator {
|
||||
return (Story) => (
|
||||
<AuthContext.Provider value={value}>
|
||||
<Story />
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
const meta: Meta<typeof RequireAuth> = {
|
||||
title: "Auth/Guards/Require Auth",
|
||||
component: RequireAuth,
|
||||
parameters: { layout: "centered" },
|
||||
args: {
|
||||
children: <Panel label="Protected content" />,
|
||||
fallback: <Panel label="Login screen (fallback)" />,
|
||||
loading: <Panel label="Resolving session…" />,
|
||||
},
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof RequireAuth>;
|
||||
|
||||
/** A resolved session: the guard renders the protected tree. */
|
||||
export const Authenticated: Story = {
|
||||
decorators: [withAuth(authValue({ session: activeSession }))],
|
||||
};
|
||||
|
||||
/** No session: the caller's login screen takes over. */
|
||||
export const SignedOut: Story = {
|
||||
decorators: [withAuth(authValue())],
|
||||
};
|
||||
|
||||
/** Session still resolving: neither content nor login screen flashes. */
|
||||
export const Loading: Story = {
|
||||
decorators: [withAuth(authValue({ loading: true }))],
|
||||
};
|
||||
|
||||
/**
|
||||
* With no `loading` slot the guard renders nothing until the session
|
||||
* resolves, which is how routes that own their own spinner use it.
|
||||
*/
|
||||
export const LoadingWithoutSlot: Story = {
|
||||
args: { loading: undefined },
|
||||
decorators: [withAuth(authValue({ loading: true }))],
|
||||
};
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { Decorator, Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { AuthContext } from "@app/auth/context";
|
||||
import type { AuthContextValue, AuthSession } from "@app/auth/types";
|
||||
import { RequirePortalAccess } from "@app/auth/guards/RequirePortalAccess";
|
||||
|
||||
/**
|
||||
* The processor/portal gate. It mirrors the admin guard but keys off
|
||||
* `portalAccess`, the backend grant that covers admins plus anyone given
|
||||
* access explicitly: `loading` while the session resolves, `fallback` when
|
||||
* signed out, and `forbidden` (alongside a call to `onForbidden`) for a
|
||||
* signed-in user without the grant.
|
||||
*
|
||||
* The guard reads three context fields, so the stories supply a slice of the
|
||||
* auth context rather than mounting a real provider, and each slot renders as
|
||||
* a labelled panel so the branch taken is visible.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Auth context slice; `session`, `loading` and `portalAccess` steer this guard.
|
||||
*/
|
||||
function authValue(
|
||||
overrides: Partial<AuthContextValue> = {},
|
||||
): AuthContextValue {
|
||||
return {
|
||||
session: null,
|
||||
user: null,
|
||||
displayName: null,
|
||||
isAnonymous: false,
|
||||
isAdmin: false,
|
||||
portalAccess: false,
|
||||
role: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
signOut: async () => {},
|
||||
refreshSession: async () => {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const activeSession: AuthSession = {
|
||||
user: { id: "1", email: "ada@example.com", username: "ada", role: "USER" },
|
||||
access_token: "storybook-token",
|
||||
expires_in: 3600,
|
||||
};
|
||||
|
||||
/** Labelled stand-in for whichever slot the guard chose to render. */
|
||||
function Panel({ label }: { label: string }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: "1.5rem",
|
||||
borderRadius: "0.75rem",
|
||||
border: "1px solid var(--c-border)",
|
||||
background: "var(--c-surface)",
|
||||
color: "var(--c-text)",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function withAuth(value: AuthContextValue): Decorator {
|
||||
return (Story) => (
|
||||
<AuthContext.Provider value={value}>
|
||||
<Story />
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
const meta: Meta<typeof RequirePortalAccess> = {
|
||||
title: "Auth/Guards/Require Portal Access",
|
||||
component: RequirePortalAccess,
|
||||
parameters: { layout: "centered" },
|
||||
args: {
|
||||
children: <Panel label="Processor content" />,
|
||||
fallback: <Panel label="Login screen (fallback)" />,
|
||||
forbidden: <Panel label="Redirecting away…" />,
|
||||
loading: <Panel label="Resolving session…" />,
|
||||
onForbidden: () => {},
|
||||
},
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof RequirePortalAccess>;
|
||||
|
||||
/** The grant is present: the processor renders. */
|
||||
export const WithAccess: Story = {
|
||||
decorators: [
|
||||
withAuth(authValue({ session: activeSession, portalAccess: true })),
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Signed in without the grant. `onForbidden` fires so the route can send the
|
||||
* user back to the editor; the forbidden slot covers the gap until it does.
|
||||
*/
|
||||
export const WithoutAccess: Story = {
|
||||
decorators: [withAuth(authValue({ session: activeSession }))],
|
||||
};
|
||||
|
||||
/** No session at all: the login screen takes over, no redirect is triggered. */
|
||||
export const SignedOut: Story = {
|
||||
decorators: [withAuth(authValue())],
|
||||
};
|
||||
|
||||
/**
|
||||
* Session still resolving. The redirect is deliberately held back here — an
|
||||
* unresolved session must not be mistaken for a missing grant.
|
||||
*/
|
||||
export const Loading: Story = {
|
||||
decorators: [withAuth(authValue({ loading: true }))],
|
||||
};
|
||||
@@ -24,6 +24,8 @@ export const oauthProviderConfig: Record<
|
||||
};
|
||||
|
||||
// Icon URLs + GENERIC_PROVIDER_ICON come from the shared oauthIcons resolver.
|
||||
// Every provider icon is decorative (alt=""): the button it sits in already
|
||||
// names the provider, so alt text would only repeat that name.
|
||||
|
||||
interface OAuthButtonsProps {
|
||||
onProviderClick: (provider: OAuthProvider) => void;
|
||||
@@ -116,7 +118,7 @@ export default function OAuthButtons({
|
||||
>
|
||||
<img
|
||||
src={oauthIconUrl(p.file)}
|
||||
alt={p.label}
|
||||
alt=""
|
||||
className="oauth-icon-small"
|
||||
/>
|
||||
</DSButton>
|
||||
@@ -142,7 +144,7 @@ export default function OAuthButtons({
|
||||
>
|
||||
<img
|
||||
src={oauthIconUrl(p.file)}
|
||||
alt={p.label}
|
||||
alt=""
|
||||
className="oauth-icon-medium"
|
||||
/>
|
||||
</DSButton>
|
||||
@@ -169,7 +171,7 @@ export default function OAuthButtons({
|
||||
<span className="oauth-btn-group">
|
||||
<img
|
||||
src={oauthIconUrl(p.file)}
|
||||
alt={p.label}
|
||||
alt=""
|
||||
className={`oauth-icon-medium oauth-icon--${p.providerId}`}
|
||||
style={{ marginRight: "0.5rem", flexShrink: 0 }}
|
||||
/>
|
||||
@@ -210,7 +212,7 @@ export default function OAuthButtons({
|
||||
<span className="oauth-icon-wrapper">
|
||||
<img
|
||||
src={oauthIconUrl(p.file)}
|
||||
alt={p.label}
|
||||
alt=""
|
||||
className="oauth-icon-tiny"
|
||||
/>
|
||||
</span>
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { MeterBar } from "@app/billing";
|
||||
import "@portal/components/billing/billing.css";
|
||||
|
||||
/**
|
||||
* The usage-meter block shared by the editor's cloud surface and the admin
|
||||
* portal. Everything visible is a prop: the caller owns the copy, and `state`
|
||||
* (FULL / WARNED / DEGRADED) drives the status-chip tone and the fill colour
|
||||
* together — the percentage alone never changes the palette. Two parts are
|
||||
* optional and are what separates most of these states: the status chip
|
||||
* (hidden when no label is given) and the fill bar (hidden when a plan has no
|
||||
* ceiling to measure against).
|
||||
*
|
||||
* The `paygf-meter` styling belongs to each host app rather than the component,
|
||||
* so these render against the portal's billing stylesheet — the same one the
|
||||
* wallet, spend-limit and prepaid cards are seen under.
|
||||
*/
|
||||
const meta: Meta<typeof MeterBar> = {
|
||||
title: "Portal/Billing/MeterBar",
|
||||
component: MeterBar,
|
||||
parameters: { layout: "padded" },
|
||||
decorators: [
|
||||
(S) => (
|
||||
<div style={{ maxWidth: "34rem" }}>
|
||||
<S />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof MeterBar>;
|
||||
|
||||
/** Comfortably inside the free grant — green chip, green fill. */
|
||||
export const Healthy: Story = {
|
||||
args: {
|
||||
state: "FULL",
|
||||
pct: 24,
|
||||
figure: "120",
|
||||
capSuffix: "/ 500 free PDFs",
|
||||
statusLabel: "Healthy",
|
||||
meta: "Resets 1 July",
|
||||
barLabel: "Free allowance",
|
||||
},
|
||||
};
|
||||
|
||||
/** Near the ceiling — amber chip and fill warn before anything is blocked. */
|
||||
export const Approaching: Story = {
|
||||
args: {
|
||||
state: "WARNED",
|
||||
pct: 86,
|
||||
figure: "$860",
|
||||
capSuffix: "/ $1,000 cap",
|
||||
statusLabel: "Approaching cap",
|
||||
meta: "Projected $1,020 by 30 June",
|
||||
barLabel: "Spend limit",
|
||||
},
|
||||
};
|
||||
|
||||
/** At the ceiling — red chip and fill; billable work is refused past this point. */
|
||||
export const CapReached: Story = {
|
||||
args: {
|
||||
state: "DEGRADED",
|
||||
pct: 100,
|
||||
figure: "$1,000",
|
||||
capSuffix: "/ $1,000 cap",
|
||||
statusLabel: "Cap reached",
|
||||
meta: "Raise the cap to resume processing",
|
||||
barLabel: "Spend limit",
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* No ceiling to fill against, so the bar is dropped and the figure carries the
|
||||
* whole meter. The chip is omitted too — there is no threshold to be near.
|
||||
*/
|
||||
export const Uncapped: Story = {
|
||||
args: {
|
||||
state: "FULL",
|
||||
pct: 0,
|
||||
figure: "$1,430",
|
||||
capSuffix: "no cap",
|
||||
showBar: false,
|
||||
meta: "Billed monthly in arrears",
|
||||
barLabel: "Spend this period",
|
||||
},
|
||||
};
|
||||
@@ -23,6 +23,8 @@ interface MeterBarProps {
|
||||
meta?: ReactNode;
|
||||
/** Hide the fill bar (e.g. uncapped). Shown by default. */
|
||||
showBar?: boolean;
|
||||
/** Accessible name for the fill bar — what the meter measures ("Spend limit"). */
|
||||
barLabel: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -40,6 +42,7 @@ export function MeterBar({
|
||||
statusLabel,
|
||||
meta,
|
||||
showBar = true,
|
||||
barLabel,
|
||||
}: MeterBarProps) {
|
||||
return (
|
||||
<div className="paygf-meter" data-state={state}>
|
||||
@@ -61,6 +64,7 @@ export function MeterBar({
|
||||
aria-valuenow={Math.round(pct)}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-label={barLabel}
|
||||
>
|
||||
<div
|
||||
className="payg-bar__fill"
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { SpendCapControl, type SpendCapControlProps } from "@app/billing";
|
||||
import "@portal/components/billing/billing.css";
|
||||
|
||||
/**
|
||||
* The monthly spend-cap editor shared by the editor's cloud surface and the
|
||||
* admin portal: preset chips, a custom-amount pill, a "no cap" chip and an
|
||||
* optional inline Save.
|
||||
*
|
||||
* The control is fully controlled, so the states below differ by what the cap
|
||||
* currently is and by which optional affordances the host asked for:
|
||||
* - a cap matching a preset selects that chip;
|
||||
* - any other number activates the custom pill instead;
|
||||
* - null is the no-cap state, which drops the estimate and adds an explainer;
|
||||
* - passing `onSave` adds the Save button, enabled only once the cap differs
|
||||
* from the persisted `savedCapUsd`.
|
||||
*
|
||||
* Copy is injected by the host (the editor passes i18n strings, the portal
|
||||
* passes literals), so these render the built-in English defaults. The `scc-*`
|
||||
* styling likewise belongs to each host — the portal's billing stylesheet here.
|
||||
*/
|
||||
const PRICE_PER_DOC_MINOR = 2;
|
||||
|
||||
/**
|
||||
* Holds the cap locally so the chips, custom field and Save button behave as
|
||||
* they do in a host that owns the value.
|
||||
*/
|
||||
function ControlledCap({
|
||||
initialCap,
|
||||
...rest
|
||||
}: { initialCap: number | null } & Omit<
|
||||
SpendCapControlProps,
|
||||
"capUsd" | "onChange"
|
||||
>) {
|
||||
const [cap, setCap] = useState<number | null>(initialCap);
|
||||
return <SpendCapControl {...rest} capUsd={cap} onChange={setCap} />;
|
||||
}
|
||||
|
||||
const meta: Meta<typeof SpendCapControl> = {
|
||||
title: "Portal/Billing/SpendCapControl",
|
||||
component: SpendCapControl,
|
||||
parameters: { layout: "padded" },
|
||||
decorators: [
|
||||
(S) => (
|
||||
<div style={{ maxWidth: "44rem" }}>
|
||||
<S />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof SpendCapControl>;
|
||||
|
||||
/** A cap on one of the presets: that chip is selected and drives the estimate. */
|
||||
export const PresetSelected: Story = {
|
||||
render: () => (
|
||||
<ControlledCap initialCap={500} pricePerDocMinor={PRICE_PER_DOC_MINOR} />
|
||||
),
|
||||
};
|
||||
|
||||
/** An amount outside the presets moves the selection into the custom pill. */
|
||||
export const CustomAmount: Story = {
|
||||
render: () => (
|
||||
<ControlledCap initialCap={1234} pricePerDocMinor={PRICE_PER_DOC_MINOR} />
|
||||
),
|
||||
};
|
||||
|
||||
/** No cap: nothing to estimate against, and the uncapped-billing note appears. */
|
||||
export const NoCap: Story = {
|
||||
render: () => (
|
||||
<ControlledCap initialCap={null} pricePerDocMinor={PRICE_PER_DOC_MINOR} />
|
||||
),
|
||||
};
|
||||
|
||||
/**
|
||||
* Hosts that persist the cap pass `onSave`, which adds the Save button. It stays
|
||||
* disabled until the chosen cap differs from the saved one — shown here already
|
||||
* dirty (saved 250, selected 1,000).
|
||||
*/
|
||||
export const WithSaveButton: Story = {
|
||||
render: () => (
|
||||
<ControlledCap
|
||||
initialCap={1000}
|
||||
savedCapUsd={250}
|
||||
onSave={async () => {}}
|
||||
pricePerDocMinor={PRICE_PER_DOC_MINOR}
|
||||
note="Changes apply from the next billing period."
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
/** Every control is inert while the host has an operation in flight. */
|
||||
export const Disabled: Story = {
|
||||
render: () => (
|
||||
<ControlledCap
|
||||
initialCap={500}
|
||||
disabled
|
||||
onSave={async () => {}}
|
||||
savedCapUsd={250}
|
||||
pricePerDocMinor={PRICE_PER_DOC_MINOR}
|
||||
/>
|
||||
),
|
||||
};
|
||||
@@ -64,7 +64,7 @@
|
||||
height: 1.75rem;
|
||||
border-radius: 9999px;
|
||||
background: var(--mantine-color-blue-light);
|
||||
color: var(--mantine-color-blue-filled);
|
||||
color: var(--c-accent-text);
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
}
|
||||
@@ -139,7 +139,7 @@
|
||||
var(--mantine-color-blue-filled) 18%,
|
||||
transparent
|
||||
);
|
||||
color: var(--mantine-color-blue-3, var(--mantine-color-blue-filled));
|
||||
color: var(--c-accent-text);
|
||||
}
|
||||
|
||||
/* Same treatment for the input pill and quick-action cards. */
|
||||
@@ -224,7 +224,7 @@
|
||||
border: 1px solid var(--mantine-color-blue-light);
|
||||
border-radius: 9999px;
|
||||
background: var(--mantine-color-blue-light);
|
||||
color: var(--mantine-color-blue-filled);
|
||||
color: var(--c-accent-text);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.1;
|
||||
@@ -302,7 +302,7 @@
|
||||
height: 2rem;
|
||||
border-radius: 0.5rem;
|
||||
background: var(--mantine-color-blue-light);
|
||||
color: var(--mantine-color-blue-filled);
|
||||
color: var(--c-accent-text);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -453,7 +453,7 @@
|
||||
}
|
||||
|
||||
.chat-message-action-btn--active {
|
||||
color: var(--mantine-color-blue-filled);
|
||||
color: var(--c-accent-text);
|
||||
}
|
||||
|
||||
.chat-message-timestamp {
|
||||
@@ -680,7 +680,7 @@
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.125rem 0.5rem;
|
||||
color: var(--mantine-color-blue-filled);
|
||||
color: var(--c-accent-text);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { ChatQuickActions } from "@app/components/chat/ChatQuickActions";
|
||||
import { FileContextProvider } from "@app/contexts/FileContext";
|
||||
import {
|
||||
FilesModalContext,
|
||||
type FilesModalContextType,
|
||||
} from "@app/contexts/FilesModalContext";
|
||||
import "@app/components/chat/ChatPanel.css";
|
||||
|
||||
/**
|
||||
* The suggestion block the assistant shows above its composer. It reads the
|
||||
* workbench rather than taking a list of actions: an empty workbench offers a
|
||||
* way to open files, one PDF offers split (only when it has more than one page)
|
||||
* and compress, several files offer merge and compress, and anything that isn't
|
||||
* a PDF adds a convert-to-PDF suggestion. With files present it also renders a
|
||||
* pill per file — three, then a "+n more" that opens the files modal.
|
||||
*
|
||||
* Only the empty-workbench state is reachable in isolation: the populated ones
|
||||
* need files loaded into FileContext, which has no seeding entry point, so they
|
||||
* belong to a story of the chat panel running against a real workbench.
|
||||
*/
|
||||
const meta: Meta<typeof ChatQuickActions> = {
|
||||
title: "Editor/Chat/ChatQuickActions",
|
||||
component: ChatQuickActions,
|
||||
parameters: { layout: "padded" },
|
||||
args: {
|
||||
heading: "What would you like to do?",
|
||||
onAction: () => {},
|
||||
},
|
||||
decorators: [
|
||||
(S) => (
|
||||
<FileContextProvider>
|
||||
{/* Only openFilesModal is read here — the real provider reaches back
|
||||
into FileContext and NavigationContext, so a slice is supplied. */}
|
||||
<FilesModalContext.Provider
|
||||
value={
|
||||
{ openFilesModal: () => {} } as unknown as FilesModalContextType
|
||||
}
|
||||
>
|
||||
<div style={{ maxWidth: "24rem" }}>
|
||||
<S />
|
||||
</div>
|
||||
</FilesModalContext.Provider>
|
||||
</FileContextProvider>
|
||||
),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof ChatQuickActions>;
|
||||
|
||||
/** Nothing in the workbench: no file pills, and a single "open files" action. */
|
||||
export const EmptyWorkbench: Story = {};
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { AppSwitcher } from "@app/components/shared/AppSwitcher";
|
||||
import { AuthContext } from "@app/auth/context";
|
||||
import type { AuthContextValue } from "@app/auth/types";
|
||||
|
||||
/**
|
||||
* The sidebar brand header on builds that bundle the admin portal. It reads a
|
||||
* single field of the auth state — `portalAccess` — and that is the whole
|
||||
* decision: users who may reach the portal get the brand switcher (clicking it
|
||||
* navigates to the processor), everyone else gets the plain logo, exactly as
|
||||
* core renders it.
|
||||
*
|
||||
* The rail also collapses, which swaps the wordmark for the icon-only mark, so
|
||||
* both widths are worth seeing on the switcher variant.
|
||||
*/
|
||||
|
||||
/** Only `portalAccess` is read; the rest is inert filler for the context shape. */
|
||||
function authWith(portalAccess: boolean): AuthContextValue {
|
||||
return {
|
||||
session: null,
|
||||
user: null,
|
||||
displayName: "Ada Lovelace",
|
||||
isAnonymous: false,
|
||||
isAdmin: portalAccess,
|
||||
portalAccess,
|
||||
role: portalAccess ? "ROLE_ADMIN" : "ROLE_USER",
|
||||
loading: false,
|
||||
error: null,
|
||||
signOut: async () => {},
|
||||
refreshSession: async () => {},
|
||||
};
|
||||
}
|
||||
|
||||
const meta: Meta<typeof AppSwitcher> = {
|
||||
// Distinct from core's "Shared/AppSwitcher": that file covers the logo-only
|
||||
// component this one shadows.
|
||||
title: "Shared/AppSwitcher (portal build)",
|
||||
component: AppSwitcher,
|
||||
parameters: { layout: "padded" },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof AppSwitcher>;
|
||||
|
||||
/** No portal access — indistinguishable from core's plain logo header. */
|
||||
export const WithoutPortalAccess: Story = {
|
||||
decorators: [
|
||||
(S) => (
|
||||
<AuthContext.Provider value={authWith(false)}>
|
||||
<S />
|
||||
</AuthContext.Provider>
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
/** Portal access — the logo becomes the editor⇄processor switcher. */
|
||||
export const WithPortalAccess: Story = {
|
||||
decorators: [
|
||||
(S) => (
|
||||
<AuthContext.Provider value={authWith(true)}>
|
||||
<S />
|
||||
</AuthContext.Provider>
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
/** The switcher on a collapsed rail: icon-only mark, no wordmark. */
|
||||
export const CollapsedWithPortalAccess: Story = {
|
||||
args: { collapsed: true },
|
||||
decorators: [
|
||||
(S) => (
|
||||
<AuthContext.Provider value={authWith(true)}>
|
||||
<S />
|
||||
</AuthContext.Provider>
|
||||
),
|
||||
],
|
||||
};
|
||||
@@ -214,229 +214,244 @@ export default function ChangeUserPasswordModal({
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
// Composed rather than the plain <Modal>, because only Modal.Content lands
|
||||
// props on the role="dialog" element — the modal draws its own heading, so
|
||||
// the dialog needs an aria-label to have an accessible name.
|
||||
<Modal.Root
|
||||
opened={opened}
|
||||
onClose={handleClose}
|
||||
size="md"
|
||||
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
|
||||
centered
|
||||
padding="xl"
|
||||
withCloseButton={false}
|
||||
>
|
||||
<div style={{ position: "relative" }}>
|
||||
<ActionIcon
|
||||
aria-label={t("common.close", "Close")}
|
||||
variant="tertiary"
|
||||
onClick={handleClose}
|
||||
size="lg"
|
||||
disabled={processing}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: -8,
|
||||
right: -8,
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
<LocalIcon icon="close-rounded" />
|
||||
</ActionIcon>
|
||||
<Stack gap="lg" pt="md">
|
||||
<Stack gap="md" align="center">
|
||||
<LocalIcon
|
||||
icon="lock"
|
||||
width="3rem"
|
||||
height="3rem"
|
||||
style={{ color: "var(--mantine-color-gray-6)" }}
|
||||
/>
|
||||
<Text size="xl" fw={600} ta="center">
|
||||
{t("workspace.people.changePassword.title", "Change password")}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
{t(
|
||||
"workspace.people.changePassword.subtitle",
|
||||
"Update the password for",
|
||||
)}{" "}
|
||||
<strong>{user?.username}</strong>
|
||||
</Text>
|
||||
</Stack>
|
||||
<Modal.Overlay />
|
||||
<Modal.Content
|
||||
aria-label={t(
|
||||
"workspace.people.changePassword.title",
|
||||
"Change password",
|
||||
)}
|
||||
>
|
||||
<Modal.Body>
|
||||
<div style={{ position: "relative" }}>
|
||||
<ActionIcon
|
||||
aria-label={t("common.close", "Close")}
|
||||
variant="tertiary"
|
||||
onClick={handleClose}
|
||||
size="lg"
|
||||
disabled={processing}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: -8,
|
||||
right: -8,
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
<LocalIcon icon="close-rounded" />
|
||||
</ActionIcon>
|
||||
<Stack gap="lg" pt="md">
|
||||
<Stack gap="md" align="center">
|
||||
<LocalIcon
|
||||
icon="lock"
|
||||
width="3rem"
|
||||
height="3rem"
|
||||
style={{ color: "var(--mantine-color-gray-6)" }}
|
||||
/>
|
||||
<Text size="xl" fw={600} ta="center">
|
||||
{t(
|
||||
"workspace.people.changePassword.title",
|
||||
"Change password",
|
||||
)}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
{t(
|
||||
"workspace.people.changePassword.subtitle",
|
||||
"Update the password for",
|
||||
)}{" "}
|
||||
<strong>{user?.username}</strong>
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<Stack gap="sm">
|
||||
<PasswordInput
|
||||
label={t(
|
||||
"workspace.people.changePassword.newPassword",
|
||||
"New password",
|
||||
)}
|
||||
placeholder={t(
|
||||
"workspace.people.changePassword.placeholder",
|
||||
"Enter a new password",
|
||||
)}
|
||||
value={form.newPassword}
|
||||
onChange={(event) =>
|
||||
setForm({
|
||||
...form,
|
||||
newPassword: event.currentTarget.value,
|
||||
generateRandom: false,
|
||||
})
|
||||
}
|
||||
disabled={processing || disabled || form.generateRandom}
|
||||
data-autofocus
|
||||
/>
|
||||
<PasswordInput
|
||||
label={t(
|
||||
"workspace.people.changePassword.confirmPassword",
|
||||
"Confirm password",
|
||||
)}
|
||||
placeholder={t(
|
||||
"workspace.people.changePassword.confirmPlaceholder",
|
||||
"Re-enter the new password",
|
||||
)}
|
||||
value={form.confirmPassword}
|
||||
onChange={(event) =>
|
||||
setForm({
|
||||
...form,
|
||||
confirmPassword: event.currentTarget.value,
|
||||
generateRandom: false,
|
||||
})
|
||||
}
|
||||
disabled={processing || disabled || form.generateRandom}
|
||||
error={
|
||||
!form.generateRandom &&
|
||||
form.confirmPassword &&
|
||||
form.newPassword !== form.confirmPassword
|
||||
? t(
|
||||
"workspace.people.changePassword.passwordMismatch",
|
||||
"Passwords do not match",
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<Group justify="space-between">
|
||||
<Checkbox
|
||||
label={t(
|
||||
"workspace.people.changePassword.generateRandom",
|
||||
"Generate secure password",
|
||||
)}
|
||||
checked={form.generateRandom}
|
||||
disabled={processing || disabled}
|
||||
onChange={(event) => {
|
||||
const checked = event.currentTarget.checked;
|
||||
setForm((prev) => ({ ...prev, generateRandom: checked }));
|
||||
if (event.currentTarget.checked) {
|
||||
handleGeneratePassword();
|
||||
<Stack gap="sm">
|
||||
<PasswordInput
|
||||
label={t(
|
||||
"workspace.people.changePassword.newPassword",
|
||||
"New password",
|
||||
)}
|
||||
placeholder={t(
|
||||
"workspace.people.changePassword.placeholder",
|
||||
"Enter a new password",
|
||||
)}
|
||||
value={form.newPassword}
|
||||
onChange={(event) =>
|
||||
setForm({
|
||||
...form,
|
||||
newPassword: event.currentTarget.value,
|
||||
generateRandom: false,
|
||||
})
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{passwordPreview && (
|
||||
<Group gap="xs" align="center">
|
||||
disabled={processing || disabled || form.generateRandom}
|
||||
data-autofocus
|
||||
/>
|
||||
<PasswordInput
|
||||
label={t(
|
||||
"workspace.people.changePassword.confirmPassword",
|
||||
"Confirm password",
|
||||
)}
|
||||
placeholder={t(
|
||||
"workspace.people.changePassword.confirmPlaceholder",
|
||||
"Re-enter the new password",
|
||||
)}
|
||||
value={form.confirmPassword}
|
||||
onChange={(event) =>
|
||||
setForm({
|
||||
...form,
|
||||
confirmPassword: event.currentTarget.value,
|
||||
generateRandom: false,
|
||||
})
|
||||
}
|
||||
disabled={processing || disabled || form.generateRandom}
|
||||
error={
|
||||
!form.generateRandom &&
|
||||
form.confirmPassword &&
|
||||
form.newPassword !== form.confirmPassword
|
||||
? t(
|
||||
"workspace.people.changePassword.passwordMismatch",
|
||||
"Passwords do not match",
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<Group justify="space-between">
|
||||
<Checkbox
|
||||
label={t(
|
||||
"workspace.people.changePassword.generateRandom",
|
||||
"Generate secure password",
|
||||
)}
|
||||
checked={form.generateRandom}
|
||||
disabled={processing || disabled}
|
||||
onChange={(event) => {
|
||||
const checked = event.currentTarget.checked;
|
||||
setForm((prev) => ({ ...prev, generateRandom: checked }));
|
||||
if (event.currentTarget.checked) {
|
||||
handleGeneratePassword();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{passwordPreview && (
|
||||
<Group gap="xs" align="center">
|
||||
<Text size="xs" c="dimmed">
|
||||
{t(
|
||||
"workspace.people.changePassword.generatedPreview",
|
||||
"Generated password:",
|
||||
)}{" "}
|
||||
<strong>{passwordPreview}</strong>
|
||||
</Text>
|
||||
<Tooltip
|
||||
label={t(
|
||||
"workspace.people.changePassword.copyTooltip",
|
||||
"Copy to clipboard",
|
||||
)}
|
||||
>
|
||||
<ActionIcon
|
||||
aria-label={t(
|
||||
"workspace.people.changePassword.copyTooltip",
|
||||
"Copy to clipboard",
|
||||
)}
|
||||
size="sm"
|
||||
variant="tertiary"
|
||||
onClick={handleCopyPassword}
|
||||
disabled={processing}
|
||||
>
|
||||
<LocalIcon
|
||||
icon="content-copy"
|
||||
width="0.9rem"
|
||||
height="0.9rem"
|
||||
/>
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
<Stack gap="xs">
|
||||
<Checkbox
|
||||
label={t(
|
||||
"workspace.people.changePassword.sendEmail",
|
||||
"Email the user about this change",
|
||||
)}
|
||||
checked={canEmail && form.sendEmail}
|
||||
onChange={(event) =>
|
||||
setForm({ ...form, sendEmail: event.currentTarget.checked })
|
||||
}
|
||||
disabled={!canEmail || processing}
|
||||
/>
|
||||
<Checkbox
|
||||
label={t(
|
||||
"workspace.people.changePassword.includePassword",
|
||||
"Include the new password in the email",
|
||||
)}
|
||||
checked={canEmail && form.sendEmail && form.includePassword}
|
||||
onChange={(event) =>
|
||||
setForm({
|
||||
...form,
|
||||
includePassword: event.currentTarget.checked,
|
||||
})
|
||||
}
|
||||
disabled={!canEmail || !form.sendEmail || processing}
|
||||
/>
|
||||
<Checkbox
|
||||
label={t(
|
||||
"workspace.people.changePassword.forcePasswordChange",
|
||||
"Force user to change password on next login",
|
||||
)}
|
||||
checked={form.forcePasswordChange}
|
||||
onChange={(event) =>
|
||||
setForm({
|
||||
...form,
|
||||
forcePasswordChange: event.currentTarget.checked,
|
||||
})
|
||||
}
|
||||
disabled={processing || disabled}
|
||||
/>
|
||||
{!canEmail && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{mailEnabled
|
||||
? t(
|
||||
"workspace.people.changePassword.emailUnavailable",
|
||||
"This user's email is not a valid email address. Notifications are disabled.",
|
||||
)
|
||||
: t(
|
||||
"workspace.people.changePassword.smtpDisabled",
|
||||
"Email notifications require SMTP to be enabled in settings.",
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
{canEmail && !form.includePassword && form.sendEmail && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t(
|
||||
"workspace.people.changePassword.generatedPreview",
|
||||
"Generated password:",
|
||||
)}{" "}
|
||||
<strong>{passwordPreview}</strong>
|
||||
"workspace.people.changePassword.notifyOnly",
|
||||
"An email will be sent without the password, letting the user know an admin changed it.",
|
||||
)}
|
||||
</Text>
|
||||
<Tooltip
|
||||
label={t(
|
||||
"workspace.people.changePassword.copyTooltip",
|
||||
"Copy to clipboard",
|
||||
)}
|
||||
>
|
||||
<ActionIcon
|
||||
aria-label={t(
|
||||
"workspace.people.changePassword.copyTooltip",
|
||||
"Copy to clipboard",
|
||||
)}
|
||||
size="sm"
|
||||
variant="tertiary"
|
||||
onClick={handleCopyPassword}
|
||||
disabled={processing}
|
||||
>
|
||||
<LocalIcon
|
||||
icon="content-copy"
|
||||
width="0.9rem"
|
||||
height="0.9rem"
|
||||
/>
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
<Stack gap="xs">
|
||||
<Checkbox
|
||||
label={t(
|
||||
"workspace.people.changePassword.sendEmail",
|
||||
"Email the user about this change",
|
||||
)}
|
||||
checked={canEmail && form.sendEmail}
|
||||
onChange={(event) =>
|
||||
setForm({ ...form, sendEmail: event.currentTarget.checked })
|
||||
}
|
||||
disabled={!canEmail || processing}
|
||||
/>
|
||||
<Checkbox
|
||||
label={t(
|
||||
"workspace.people.changePassword.includePassword",
|
||||
"Include the new password in the email",
|
||||
)}
|
||||
checked={canEmail && form.sendEmail && form.includePassword}
|
||||
onChange={(event) =>
|
||||
setForm({
|
||||
...form,
|
||||
includePassword: event.currentTarget.checked,
|
||||
})
|
||||
}
|
||||
disabled={!canEmail || !form.sendEmail || processing}
|
||||
/>
|
||||
<Checkbox
|
||||
label={t(
|
||||
"workspace.people.changePassword.forcePasswordChange",
|
||||
"Force user to change password on next login",
|
||||
)}
|
||||
checked={form.forcePasswordChange}
|
||||
onChange={(event) =>
|
||||
setForm({
|
||||
...form,
|
||||
forcePasswordChange: event.currentTarget.checked,
|
||||
})
|
||||
}
|
||||
disabled={processing || disabled}
|
||||
/>
|
||||
{!canEmail && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{mailEnabled
|
||||
? t(
|
||||
"workspace.people.changePassword.emailUnavailable",
|
||||
"This user's email is not a valid email address. Notifications are disabled.",
|
||||
)
|
||||
: t(
|
||||
"workspace.people.changePassword.smtpDisabled",
|
||||
"Email notifications require SMTP to be enabled in settings.",
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
{canEmail && !form.includePassword && form.sendEmail && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t(
|
||||
"workspace.people.changePassword.notifyOnly",
|
||||
"An email will be sent without the password, letting the user know an admin changed it.",
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
loading={processing}
|
||||
fullWidth
|
||||
size="md"
|
||||
disabled={disabled}
|
||||
style={{ marginTop: "var(--mantine-spacing-md)" }}
|
||||
>
|
||||
{t("workspace.people.changePassword.submit", "Update password")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</div>
|
||||
</Modal>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
loading={processing}
|
||||
fullWidth
|
||||
size="md"
|
||||
disabled={disabled}
|
||||
style={{ marginTop: "var(--mantine-spacing-md)" }}
|
||||
>
|
||||
{t("workspace.people.changePassword.submit", "Update password")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</div>
|
||||
</Modal.Body>
|
||||
</Modal.Content>
|
||||
</Modal.Root>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { PolicyEnforcingOverlay } from "@app/components/shared/PolicyEnforcingOverlay";
|
||||
|
||||
/**
|
||||
* The frosted-glass cover shown over a document while a policy runs against it.
|
||||
* It fills its nearest positioned ancestor, so it serves both the full-screen
|
||||
* viewer and a single thumbnail card.
|
||||
*
|
||||
* What changes between states: whether the run reports step counts (a
|
||||
* determinate progress bar) or not (a spinner); whether the caller allows the
|
||||
* user through anyway (the dismiss button); and which policy is enforcing —
|
||||
* the accent colour and category icon are passed in so the overlay matches that
|
||||
* policy's badge instead of a fixed blue shield. `enforcing: false` renders
|
||||
* nothing at all, so it isn't a story.
|
||||
*/
|
||||
const meta: Meta<typeof PolicyEnforcingOverlay> = {
|
||||
title: "Shared/PolicyEnforcingOverlay",
|
||||
component: PolicyEnforcingOverlay,
|
||||
parameters: { layout: "padded" },
|
||||
args: { enforcing: true },
|
||||
decorators: [
|
||||
(S) => (
|
||||
// Stands in for the surface being covered — the overlay needs a
|
||||
// positioned ancestor with real dimensions to render into.
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
width: "100%",
|
||||
maxWidth: "34rem",
|
||||
height: "22rem",
|
||||
borderRadius: "0.5rem",
|
||||
border: "1px solid var(--c-border)",
|
||||
background: "var(--c-surface)",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<S />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof PolicyEnforcingOverlay>;
|
||||
|
||||
/** A run with no step counts: an indeterminate spinner and the generic shield. */
|
||||
export const Indeterminate: Story = {};
|
||||
|
||||
/** A run that reports its steps swaps the spinner for a determinate bar. */
|
||||
export const WithProgress: Story = {
|
||||
args: { progress: 62 },
|
||||
};
|
||||
|
||||
/** Callers that let the user look at the file anyway get a dismiss button. */
|
||||
export const Dismissible: Story = {
|
||||
args: { progress: 40, onDismiss: () => {} },
|
||||
};
|
||||
|
||||
/**
|
||||
* Tinted to the enforcing policy: the classification category's label icon and
|
||||
* its badge colour replace the default shield and blue.
|
||||
*/
|
||||
export const ClassificationPolicy: Story = {
|
||||
args: {
|
||||
progress: 30,
|
||||
categoryId: "classification",
|
||||
accentVar: "var(--color-orange)",
|
||||
},
|
||||
};
|
||||
@@ -106,6 +106,9 @@ export function PolicyEnforcingOverlay({
|
||||
w="100%"
|
||||
size="xs"
|
||||
radius="xl"
|
||||
/* The heading above is plain text, so the bar carries its own
|
||||
name rather than being announced as an unlabelled progressbar. */
|
||||
aria-label={t("policy.enforcingTitle", "Enforcing policy…")}
|
||||
value={progress}
|
||||
striped
|
||||
animated
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import type { ReactElement } from "react";
|
||||
import UpdateSeatsButton from "@app/components/shared/UpdateSeatsButton";
|
||||
import UpdateSeatsContext from "@app/contexts/UpdateSeatsContext";
|
||||
|
||||
/**
|
||||
* The "Update Seats" entry point on enterprise licences. It owns no state of its
|
||||
* own: pressing it asks the seat-update flow to open, and the only thing that
|
||||
* changes its appearance is that flow reporting work in progress — while a seat
|
||||
* change is being prepared (a licence read, then a redirect to the Stripe
|
||||
* billing portal) the button shows its loading state.
|
||||
*
|
||||
* Everything else on it is pass-through Button styling, so those belong to the
|
||||
* shared Button's own stories rather than here.
|
||||
*/
|
||||
|
||||
/** Stands in for the seat-update flow; only these four fields are read. */
|
||||
function withSeatsFlow(isLoading: boolean) {
|
||||
return function SeatsFlowDecorator(Story: () => ReactElement) {
|
||||
return (
|
||||
<UpdateSeatsContext.Provider
|
||||
value={{
|
||||
openUpdateSeats: async () => {},
|
||||
closeUpdateSeats: () => {},
|
||||
isOpen: false,
|
||||
isLoading,
|
||||
}}
|
||||
>
|
||||
<Story />
|
||||
</UpdateSeatsContext.Provider>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
const meta: Meta<typeof UpdateSeatsButton> = {
|
||||
title: "Shared/UpdateSeatsButton",
|
||||
component: UpdateSeatsButton,
|
||||
parameters: { layout: "centered" },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof UpdateSeatsButton>;
|
||||
|
||||
/** Idle — the secondary-variant button awaiting a press. */
|
||||
export const Default: Story = {
|
||||
decorators: [withSeatsFlow(false)],
|
||||
};
|
||||
|
||||
/** The seat-update flow is preparing the billing-portal redirect. */
|
||||
export const Loading: Story = {
|
||||
decorators: [withSeatsFlow(true)],
|
||||
};
|
||||
+1
-1
@@ -604,7 +604,7 @@ const AccountSection: React.FC = () => {
|
||||
{t("account.mfa.manualKey", "Manual setup key")}:{" "}
|
||||
<strong>{mfaSetupData.secret}</strong>
|
||||
</Text>
|
||||
<Text size="xs" c="orange">
|
||||
<Text size="xs" c="var(--color-amber-dark)">
|
||||
{t(
|
||||
"account.mfa.secretWarning",
|
||||
"Keep this key private. Anyone with access can generate valid authentication codes.",
|
||||
|
||||
+3
-3
@@ -658,7 +658,7 @@ export default function AdminConnectionsSection() {
|
||||
href="https://docs.stirlingpdf.com/Functionality/Mobile-Scanner"
|
||||
target="_blank"
|
||||
size="xs"
|
||||
c="blue"
|
||||
c="var(--c-accent-text)"
|
||||
>
|
||||
{t(
|
||||
"admin.settings.connections.documentation",
|
||||
@@ -687,7 +687,7 @@ export default function AdminConnectionsSection() {
|
||||
"Allow users to upload files from mobile devices by scanning a QR code",
|
||||
)}
|
||||
</Text>
|
||||
<Text size="xs" c="orange" mt={8} fw={500}>
|
||||
<Text size="xs" c="var(--color-amber-dark)" mt={8} fw={500}>
|
||||
{t(
|
||||
"admin.settings.connections.mobileScanner.note",
|
||||
"Note: Requires Frontend URL to be configured. ",
|
||||
@@ -698,7 +698,7 @@ export default function AdminConnectionsSection() {
|
||||
e.preventDefault();
|
||||
navigate("/settings/adminGeneral#frontendUrl");
|
||||
}}
|
||||
c="orange"
|
||||
c="var(--color-amber-dark)"
|
||||
td="underline"
|
||||
>
|
||||
{t(
|
||||
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
import type React from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { http, HttpResponse, delay } from "msw";
|
||||
import AdminEndpointsSection from "@app/components/shared/config/configSections/AdminEndpointsSection";
|
||||
import { AppConfigProvider } from "@app/contexts/AppConfigContext";
|
||||
import { UnsavedChangesProvider } from "@app/contexts/UnsavedChangesContext";
|
||||
|
||||
/**
|
||||
* Admin settings section for switching individual API endpoints (and whole
|
||||
* endpoint groups) off, plus the instance-wide defaults for how unavailable
|
||||
* tools are presented to users.
|
||||
*
|
||||
* It drives two independent settings sections — `endpoints` for the two
|
||||
* multi-selects and `ui` for the two preference switches — through separate
|
||||
* useAdminSettings instances, so every story mocks both GETs; either one still
|
||||
* loading holds the whole section on its loader. The selectable endpoint and
|
||||
* group lists are hardcoded in the component, so the response only decides which
|
||||
* of them are already picked.
|
||||
*
|
||||
* Login mode off suppresses both fetches, shows the login-required banner and
|
||||
* locks every control, so it is supplied as a decorator rather than a response.
|
||||
*/
|
||||
function withProviders(enableLogin: boolean) {
|
||||
return function Decorator(Story: () => React.JSX.Element) {
|
||||
return (
|
||||
<AppConfigProvider
|
||||
autoFetch={false}
|
||||
bootstrapMode="non-blocking"
|
||||
initialConfig={{ enableLogin }}
|
||||
>
|
||||
<UnsavedChangesProvider>
|
||||
<Story />
|
||||
</UnsavedChangesProvider>
|
||||
</AppConfigProvider>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
function sectionHandlers(
|
||||
endpoints: Record<string, unknown>,
|
||||
ui: Record<string, unknown>,
|
||||
) {
|
||||
return [
|
||||
http.get("/api/v1/admin/settings/section/endpoints", () =>
|
||||
HttpResponse.json(endpoints),
|
||||
),
|
||||
http.get("/api/v1/admin/settings/section/ui", () => HttpResponse.json(ui)),
|
||||
];
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: "Config/AdminEndpointsSection",
|
||||
component: AdminEndpointsSection,
|
||||
parameters: {
|
||||
layout: "padded",
|
||||
msw: {
|
||||
handlers: [
|
||||
...sectionHandlers({}, {}),
|
||||
http.put("/api/v1/admin/settings/section/endpoints", () =>
|
||||
HttpResponse.json({}),
|
||||
),
|
||||
http.put("/api/v1/admin/settings/section/ui", () =>
|
||||
HttpResponse.json({}),
|
||||
),
|
||||
http.put("/api/v1/admin/settings", () => HttpResponse.json({})),
|
||||
],
|
||||
},
|
||||
},
|
||||
decorators: [withProviders(true)],
|
||||
} satisfies Meta<typeof AdminEndpointsSection>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
/** A stock install: nothing disabled, so both multi-selects show their placeholders. */
|
||||
export const Default: Story = {};
|
||||
|
||||
/**
|
||||
* A locked-down instance: several endpoints and two whole groups disabled, and
|
||||
* the hidden-when-unavailable defaults turned on.
|
||||
*/
|
||||
export const EndpointsDisabled: Story = {
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: sectionHandlers(
|
||||
{
|
||||
toRemove: ["add-password", "remove-password", "show-javascript"],
|
||||
groupsToRemove: ["DeveloperTools", "Automation"],
|
||||
},
|
||||
{
|
||||
defaultHideUnavailableTools: true,
|
||||
defaultHideUnavailableConversions: true,
|
||||
},
|
||||
),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** While either settings request is in flight, a centred loader replaces the form. */
|
||||
export const Loading: Story = {
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [
|
||||
http.get("/api/v1/admin/settings/section/endpoints", async () => {
|
||||
await delay("infinite");
|
||||
return HttpResponse.json({});
|
||||
}),
|
||||
http.get("/api/v1/admin/settings/section/ui", () =>
|
||||
HttpResponse.json({}),
|
||||
),
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Restart-required changes on both sections at once: the pending selections and
|
||||
* switch positions are shown, each flagged with a pending badge.
|
||||
*/
|
||||
export const PendingChanges: Story = {
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: sectionHandlers(
|
||||
{
|
||||
toRemove: ["add-password"],
|
||||
groupsToRemove: [],
|
||||
_pending: { groupsToRemove: ["DeveloperTools"] },
|
||||
},
|
||||
{
|
||||
defaultHideUnavailableTools: false,
|
||||
_pending: { defaultHideUnavailableTools: true },
|
||||
},
|
||||
),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** Login mode disabled: nothing is fetched, the banner shows and the controls lock. */
|
||||
export const LoginDisabled: Story = {
|
||||
decorators: [withProviders(false)],
|
||||
};
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
import type React from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { http, HttpResponse, delay } from "msw";
|
||||
import AdminFeaturesSection from "@app/components/shared/config/configSections/AdminFeaturesSection";
|
||||
import { AppConfigProvider } from "@app/contexts/AppConfigContext";
|
||||
import { UnsavedChangesProvider } from "@app/contexts/UnsavedChangesContext";
|
||||
|
||||
/**
|
||||
* Admin settings section for the server certificate used by "Sign with
|
||||
* Stirling-PDF".
|
||||
*
|
||||
* The whole form is read out of the `system` settings section's
|
||||
* serverCertificate node, so each story is defined by what that one GET
|
||||
* returns. When the node is absent the component substitutes its own defaults
|
||||
* rather than showing an empty form — worth seeing, since a fresh install hits
|
||||
* that path. Restart-required edits arrive under `system._pending`, which the
|
||||
* component re-keys onto serverCertificate before merging.
|
||||
*
|
||||
* Login mode off suppresses the fetch, shows the login-required banner and locks
|
||||
* every control, so it is supplied as a decorator rather than a response.
|
||||
*/
|
||||
function withProviders(enableLogin: boolean) {
|
||||
return function Decorator(Story: () => React.JSX.Element) {
|
||||
return (
|
||||
<AppConfigProvider
|
||||
autoFetch={false}
|
||||
bootstrapMode="non-blocking"
|
||||
initialConfig={{ enableLogin }}
|
||||
>
|
||||
<UnsavedChangesProvider>
|
||||
<Story />
|
||||
</UnsavedChangesProvider>
|
||||
</AppConfigProvider>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
function systemHandler(body: Record<string, unknown>) {
|
||||
return http.get("/api/v1/admin/settings/section/system", () =>
|
||||
HttpResponse.json(body),
|
||||
);
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: "Config/AdminFeaturesSection",
|
||||
component: AdminFeaturesSection,
|
||||
parameters: {
|
||||
layout: "padded",
|
||||
msw: {
|
||||
handlers: [
|
||||
systemHandler({
|
||||
serverCertificate: {
|
||||
enabled: true,
|
||||
organizationName: "Acme Legal Ltd",
|
||||
validity: 730,
|
||||
regenerateOnStartup: false,
|
||||
},
|
||||
}),
|
||||
http.put("/api/v1/admin/settings", () => HttpResponse.json({})),
|
||||
http.put("/api/v1/admin/settings/section/features", () =>
|
||||
HttpResponse.json({}),
|
||||
),
|
||||
],
|
||||
},
|
||||
},
|
||||
decorators: [withProviders(true)],
|
||||
} satisfies Meta<typeof AdminFeaturesSection>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
/** A configured certificate: named organisation and a two-year validity. */
|
||||
export const Default: Story = {};
|
||||
|
||||
/** Certificate signing turned off, leaving the detail fields editable but inert. */
|
||||
export const CertificateDisabled: Story = {
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [
|
||||
systemHandler({
|
||||
serverCertificate: {
|
||||
enabled: false,
|
||||
organizationName: "Acme Legal Ltd",
|
||||
validity: 730,
|
||||
regenerateOnStartup: false,
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** No serverCertificate node on the server — the form falls back to its built-in defaults. */
|
||||
export const UnconfiguredFallsBackToDefaults: Story = {
|
||||
parameters: { msw: { handlers: [systemHandler({})] } },
|
||||
};
|
||||
|
||||
/** While the settings request is in flight, a centred loader replaces the form. */
|
||||
export const Loading: Story = {
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [
|
||||
http.get("/api/v1/admin/settings/section/system", async () => {
|
||||
await delay("infinite");
|
||||
return HttpResponse.json({});
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** Restart-required edits: the pending values are shown, each with a pending badge. */
|
||||
export const PendingChanges: Story = {
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [
|
||||
systemHandler({
|
||||
serverCertificate: {
|
||||
enabled: true,
|
||||
organizationName: "Acme Legal Ltd",
|
||||
validity: 730,
|
||||
regenerateOnStartup: false,
|
||||
},
|
||||
_pending: {
|
||||
serverCertificate: {
|
||||
organizationName: "Acme Legal International",
|
||||
validity: 365,
|
||||
regenerateOnStartup: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** Login mode disabled: nothing is fetched, the banner shows and the controls lock. */
|
||||
export const LoginDisabled: Story = {
|
||||
decorators: [withProviders(false)],
|
||||
};
|
||||
+18
-1
@@ -173,9 +173,18 @@ export default function AdminFeaturesSection() {
|
||||
)}
|
||||
</Text>
|
||||
<Badge
|
||||
color="grape"
|
||||
/* grape-6 puts white text at 4.02:1; the darker shade clears
|
||||
4.5:1 at this size. */
|
||||
color="grape.8"
|
||||
size="sm"
|
||||
style={{ cursor: "pointer" }}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== "Enter" && e.key !== " ") return;
|
||||
e.preventDefault();
|
||||
navigate("/settings/adminPlan");
|
||||
}}
|
||||
onClick={() => navigate("/settings/adminPlan")}
|
||||
title={t(
|
||||
"admin.settings.badge.clickToUpgrade",
|
||||
@@ -216,6 +225,10 @@ export default function AdminFeaturesSection() {
|
||||
</div>
|
||||
<Group gap="xs">
|
||||
<Switch
|
||||
aria-label={t(
|
||||
"admin.settings.features.serverCertificate.enabled.label",
|
||||
"Enable Server Certificate",
|
||||
)}
|
||||
checked={settings.serverCertificate?.enabled ?? true}
|
||||
onChange={(e) => {
|
||||
if (!loginEnabled) return;
|
||||
@@ -333,6 +346,10 @@ export default function AdminFeaturesSection() {
|
||||
</div>
|
||||
<Group gap="xs">
|
||||
<Switch
|
||||
aria-label={t(
|
||||
"admin.settings.features.serverCertificate.regenerateOnStartup.label",
|
||||
"Regenerate on Startup",
|
||||
)}
|
||||
checked={
|
||||
settings.serverCertificate?.regenerateOnStartup ?? false
|
||||
}
|
||||
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
import type React from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { http, HttpResponse, delay } from "msw";
|
||||
import AdminLegalSection from "@app/components/shared/config/configSections/AdminLegalSection";
|
||||
import { AppConfigProvider } from "@app/contexts/AppConfigContext";
|
||||
import { UnsavedChangesProvider } from "@app/contexts/UnsavedChangesContext";
|
||||
|
||||
/**
|
||||
* Admin settings section for the links to legal documents, plus the login
|
||||
* agreement users must accept after signing in.
|
||||
*
|
||||
* The five URL fields and the loginAgreement node all come from the `legal`
|
||||
* settings section fetched on mount, so each story is defined by that one
|
||||
* response. The embedded LoginAgreementEditor loads its own per-language
|
||||
* markdown separately and degrades to empty text when that request is not
|
||||
* mocked, which is fine here — this file is about the section around it.
|
||||
*
|
||||
* Login mode off suppresses the fetch, shows the login-required banner and locks
|
||||
* every control, so it is supplied as a decorator rather than a response.
|
||||
*/
|
||||
function withProviders(enableLogin: boolean) {
|
||||
return function Decorator(Story: () => React.JSX.Element) {
|
||||
return (
|
||||
<AppConfigProvider
|
||||
autoFetch={false}
|
||||
bootstrapMode="non-blocking"
|
||||
initialConfig={{ enableLogin }}
|
||||
>
|
||||
<UnsavedChangesProvider>
|
||||
<Story />
|
||||
</UnsavedChangesProvider>
|
||||
</AppConfigProvider>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
function legalHandler(body: Record<string, unknown>) {
|
||||
return http.get("/api/v1/admin/settings/section/legal", () =>
|
||||
HttpResponse.json(body),
|
||||
);
|
||||
}
|
||||
|
||||
const CONFIGURED_LEGAL = {
|
||||
termsAndConditions: "https://acme-legal.test/terms",
|
||||
privacyPolicy: "https://acme-legal.test/privacy",
|
||||
accessibilityStatement: "https://acme-legal.test/accessibility",
|
||||
cookiePolicy: "https://acme-legal.test/cookies",
|
||||
impressum: "https://acme-legal.test/impressum",
|
||||
loginAgreement: {
|
||||
enabled: true,
|
||||
showInAnonymousMode: true,
|
||||
},
|
||||
};
|
||||
|
||||
const meta = {
|
||||
title: "Config/AdminLegalSection",
|
||||
component: AdminLegalSection,
|
||||
parameters: {
|
||||
layout: "padded",
|
||||
msw: {
|
||||
handlers: [
|
||||
legalHandler(CONFIGURED_LEGAL),
|
||||
http.put("/api/v1/admin/settings/section/legal", () =>
|
||||
HttpResponse.json({}),
|
||||
),
|
||||
http.put("/api/v1/admin/settings", () => HttpResponse.json({})),
|
||||
],
|
||||
},
|
||||
},
|
||||
decorators: [withProviders(true)],
|
||||
} satisfies Meta<typeof AdminLegalSection>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
/** All five documents hosted externally, with the login agreement turned on. */
|
||||
export const Default: Story = {};
|
||||
|
||||
/** A stock install: no documents overridden and no login agreement. */
|
||||
export const NothingConfigured: Story = {
|
||||
parameters: { msw: { handlers: [legalHandler({})] } },
|
||||
};
|
||||
|
||||
/** While the settings request is in flight, a centred loader replaces the form. */
|
||||
export const Loading: Story = {
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [
|
||||
http.get("/api/v1/admin/settings/section/legal", async () => {
|
||||
await delay("infinite");
|
||||
return HttpResponse.json({});
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** Restart-required document changes: the pending URLs show with pending badges. */
|
||||
export const PendingChanges: Story = {
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [
|
||||
legalHandler({
|
||||
...CONFIGURED_LEGAL,
|
||||
_pending: {
|
||||
privacyPolicy: "https://acme-legal.test/privacy-2",
|
||||
impressum: "https://acme-legal.test/legal-notice",
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** Login mode disabled: nothing is fetched, the banner shows and the form locks. */
|
||||
export const LoginDisabled: Story = {
|
||||
decorators: [withProviders(false)],
|
||||
};
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
import type React from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { http, HttpResponse, delay } from "msw";
|
||||
import AdminMailSection from "@app/components/shared/config/configSections/AdminMailSection";
|
||||
import { AppConfigProvider } from "@app/contexts/AppConfigContext";
|
||||
import { UnsavedChangesProvider } from "@app/contexts/UnsavedChangesContext";
|
||||
|
||||
/**
|
||||
* Admin settings section for the outbound SMTP configuration.
|
||||
*
|
||||
* Everything on screen comes from the `mail` settings section, fetched on mount,
|
||||
* so each story is defined by that one response. The master "Enable Mail" switch
|
||||
* gates the email-invites switch beneath it, which is the only conditional
|
||||
* rendering in the form — hence a story either side of it.
|
||||
*
|
||||
* Unlike the neighbouring admin sections this one has no login-required banner:
|
||||
* login state only reaches the sticky save footer, which stays hidden until the
|
||||
* form is edited, so a login-disabled story would be indistinguishable.
|
||||
* useSettingsDirty() and useLoginRequired() still need their providers.
|
||||
*/
|
||||
function withProviders(Story: () => React.JSX.Element) {
|
||||
return (
|
||||
<AppConfigProvider
|
||||
autoFetch={false}
|
||||
bootstrapMode="non-blocking"
|
||||
initialConfig={{ enableLogin: true }}
|
||||
>
|
||||
<UnsavedChangesProvider>
|
||||
<Story />
|
||||
</UnsavedChangesProvider>
|
||||
</AppConfigProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function mailHandler(body: Record<string, unknown>) {
|
||||
return http.get("/api/v1/admin/settings/section/mail", () =>
|
||||
HttpResponse.json(body),
|
||||
);
|
||||
}
|
||||
|
||||
const CONFIGURED_SMTP = {
|
||||
enabled: true,
|
||||
enableInvites: true,
|
||||
host: "smtp.acme-legal.test",
|
||||
port: 587,
|
||||
username: "postmaster@acme-legal.test",
|
||||
password: "correct-horse-battery-staple",
|
||||
from: "noreply@acme-legal.test",
|
||||
};
|
||||
|
||||
const meta = {
|
||||
title: "Config/AdminMailSection",
|
||||
component: AdminMailSection,
|
||||
parameters: {
|
||||
layout: "padded",
|
||||
msw: {
|
||||
handlers: [
|
||||
mailHandler(CONFIGURED_SMTP),
|
||||
http.put("/api/v1/admin/settings/section/mail", () =>
|
||||
HttpResponse.json({}),
|
||||
),
|
||||
http.put("/api/v1/admin/settings", () => HttpResponse.json({})),
|
||||
],
|
||||
},
|
||||
},
|
||||
decorators: [withProviders],
|
||||
} satisfies Meta<typeof AdminMailSection>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
/** A working relay: host, credentials and sender all set, invites available. */
|
||||
export const Default: Story = {};
|
||||
|
||||
/** Mail switched off — the invites switch below it is disabled along with it. */
|
||||
export const MailDisabled: Story = {
|
||||
parameters: {
|
||||
msw: { handlers: [mailHandler({ enabled: false, enableInvites: false })] },
|
||||
},
|
||||
};
|
||||
|
||||
/** Nothing configured yet: every field falls back to its placeholder, port to 587. */
|
||||
export const Unconfigured: Story = {
|
||||
parameters: { msw: { handlers: [mailHandler({})] } },
|
||||
};
|
||||
|
||||
/** While the settings request is in flight, a centred loader replaces the form. */
|
||||
export const Loading: Story = {
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [
|
||||
http.get("/api/v1/admin/settings/section/mail", async () => {
|
||||
await delay("infinite");
|
||||
return HttpResponse.json({});
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* A relay move saved but awaiting a restart: the pending host, port and sender
|
||||
* are shown, each flagged with a pending badge.
|
||||
*/
|
||||
export const PendingChanges: Story = {
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [
|
||||
mailHandler({
|
||||
...CONFIGURED_SMTP,
|
||||
_pending: {
|
||||
host: "smtp.eu.acme-legal.test",
|
||||
port: 465,
|
||||
from: "no-reply@acme-legal.test",
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
+10
-2
@@ -145,6 +145,10 @@ export default function AdminMailSection() {
|
||||
</div>
|
||||
<Group gap="xs">
|
||||
<Switch
|
||||
aria-label={t(
|
||||
"admin.settings.mail.enabled.label",
|
||||
"Enable Mail",
|
||||
)}
|
||||
checked={settings.enabled || false}
|
||||
onChange={(e) =>
|
||||
setSettings({ ...settings, enabled: e.target.checked })
|
||||
@@ -168,7 +172,7 @@ export default function AdminMailSection() {
|
||||
"Allow admins to invite users via email with auto-generated passwords",
|
||||
)}
|
||||
</Text>
|
||||
<Text size="xs" c="orange" mt={8} fw={500}>
|
||||
<Text size="xs" c="var(--color-amber-dark)" mt={8} fw={500}>
|
||||
{t(
|
||||
"admin.settings.mail.frontendUrlNote.note",
|
||||
"Note: Requires Frontend URL to be configured. ",
|
||||
@@ -179,7 +183,7 @@ export default function AdminMailSection() {
|
||||
e.preventDefault();
|
||||
navigate("/settings/adminGeneral#frontendUrl");
|
||||
}}
|
||||
c="orange"
|
||||
c="var(--color-amber-dark)"
|
||||
td="underline"
|
||||
>
|
||||
{t(
|
||||
@@ -191,6 +195,10 @@ export default function AdminMailSection() {
|
||||
</div>
|
||||
<Group gap="xs">
|
||||
<Switch
|
||||
aria-label={t(
|
||||
"admin.settings.mail.invites.label",
|
||||
"Team invitation emails",
|
||||
)}
|
||||
checked={settings.enableInvites || false}
|
||||
onChange={(e) =>
|
||||
setSettings({
|
||||
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
import type React from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { http, HttpResponse, delay } from "msw";
|
||||
import AdminPremiumSection from "@app/components/shared/config/configSections/AdminPremiumSection";
|
||||
import { AppConfigProvider } from "@app/contexts/AppConfigContext";
|
||||
import { UnsavedChangesProvider } from "@app/contexts/UnsavedChangesContext";
|
||||
|
||||
/**
|
||||
* Admin settings section for the premium/enterprise licence key.
|
||||
*
|
||||
* The component takes no props: it fetches the `premium` settings blob through
|
||||
* useAdminSettings on mount and reads login state through useLoginRequired(),
|
||||
* so what it renders is decided by two things — the mocked GET response and
|
||||
* whether login mode is on. Login mode off suppresses the fetch entirely, shows
|
||||
* the login-required banner and locks every control, which is why that state
|
||||
* gets its own decorator rather than a different response.
|
||||
*
|
||||
* useSettingsDirty() and useAppConfig() both throw without their providers, so
|
||||
* every story wraps in both.
|
||||
*/
|
||||
function withProviders(enableLogin: boolean) {
|
||||
return function Decorator(Story: () => React.JSX.Element) {
|
||||
return (
|
||||
<AppConfigProvider
|
||||
autoFetch={false}
|
||||
bootstrapMode="non-blocking"
|
||||
initialConfig={{ enableLogin }}
|
||||
>
|
||||
<UnsavedChangesProvider>
|
||||
<Story />
|
||||
</UnsavedChangesProvider>
|
||||
</AppConfigProvider>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
function premiumHandler(body: Record<string, unknown>) {
|
||||
return http.get("/api/v1/admin/settings/section/premium", () =>
|
||||
HttpResponse.json(body),
|
||||
);
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: "Config/AdminPremiumSection",
|
||||
component: AdminPremiumSection,
|
||||
parameters: {
|
||||
layout: "padded",
|
||||
msw: {
|
||||
handlers: [
|
||||
premiumHandler({
|
||||
key: "STORY-LICENCE-0000-0000-0000",
|
||||
enabled: true,
|
||||
}),
|
||||
http.put("/api/v1/admin/settings/section/premium", () =>
|
||||
HttpResponse.json({}),
|
||||
),
|
||||
http.put("/api/v1/admin/settings", () => HttpResponse.json({})),
|
||||
],
|
||||
},
|
||||
},
|
||||
decorators: [withProviders(true)],
|
||||
} satisfies Meta<typeof AdminPremiumSection>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
/** An activated licence: key populated and premium features switched on. */
|
||||
export const Default: Story = {};
|
||||
|
||||
/** No licence configured yet — the empty key field shows its placeholder. */
|
||||
export const NoLicence: Story = {
|
||||
parameters: {
|
||||
msw: { handlers: [premiumHandler({ key: "", enabled: false })] },
|
||||
},
|
||||
};
|
||||
|
||||
/** While the settings request is in flight, a centred loader replaces the form. */
|
||||
export const Loading: Story = {
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [
|
||||
http.get("/api/v1/admin/settings/section/premium", async () => {
|
||||
await delay("infinite");
|
||||
return HttpResponse.json({});
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Edits saved but awaiting a restart: the merged values are shown with a
|
||||
* pending badge beside each field the restart will change.
|
||||
*/
|
||||
export const PendingChanges: Story = {
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [
|
||||
premiumHandler({
|
||||
key: "STORY-LICENCE-0000-0000-0000",
|
||||
enabled: false,
|
||||
_pending: {
|
||||
key: "STORY-LICENCE-1111-1111-1111",
|
||||
enabled: true,
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** Login mode disabled: nothing is fetched, the banner shows and the controls lock. */
|
||||
export const LoginDisabled: Story = {
|
||||
decorators: [withProviders(false)],
|
||||
};
|
||||
+4
@@ -213,6 +213,10 @@ export default function AdminPremiumSection() {
|
||||
</div>
|
||||
<Group gap="xs">
|
||||
<Switch
|
||||
aria-label={t(
|
||||
"admin.settings.premium.enabled.label",
|
||||
"Enable Premium Features",
|
||||
)}
|
||||
checked={settings.enabled || false}
|
||||
onChange={(e) => {
|
||||
if (!loginEnabled) return;
|
||||
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
import type React from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { http, HttpResponse, delay } from "msw";
|
||||
import AdminPrivacySection from "@app/components/shared/config/configSections/AdminPrivacySection";
|
||||
import { AppConfigProvider } from "@app/contexts/AppConfigContext";
|
||||
import { UnsavedChangesProvider } from "@app/contexts/UnsavedChangesContext";
|
||||
|
||||
/**
|
||||
* Admin settings section for analytics, metrics and search-engine visibility.
|
||||
*
|
||||
* The three switches are stitched together from two separate backend sections —
|
||||
* `system` carries enableAnalytics/googlevisibility, `metrics` carries enabled —
|
||||
* so every story has to mock both GETs; a missing one leaves the section stuck
|
||||
* on its loader. Pending (restart-required) changes arrive in a `_pending` block
|
||||
* on whichever endpoint owns the field, and the component maps them onto its own
|
||||
* camelCase names, so the pending story splits them across both responses.
|
||||
*
|
||||
* Login mode off skips the fetch entirely, shows the login-required banner and
|
||||
* disables the switches, which is why it is a decorator rather than a response.
|
||||
*/
|
||||
function withProviders(enableLogin: boolean) {
|
||||
return function Decorator(Story: () => React.JSX.Element) {
|
||||
return (
|
||||
<AppConfigProvider
|
||||
autoFetch={false}
|
||||
bootstrapMode="non-blocking"
|
||||
initialConfig={{ enableLogin }}
|
||||
>
|
||||
<UnsavedChangesProvider>
|
||||
<Story />
|
||||
</UnsavedChangesProvider>
|
||||
</AppConfigProvider>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
function systemHandler(body: Record<string, unknown>) {
|
||||
return http.get("/api/v1/admin/settings/section/system", () =>
|
||||
HttpResponse.json(body),
|
||||
);
|
||||
}
|
||||
|
||||
function metricsHandler(body: Record<string, unknown>) {
|
||||
return http.get("/api/v1/admin/settings/section/metrics", () =>
|
||||
HttpResponse.json(body),
|
||||
);
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: "Config/AdminPrivacySection",
|
||||
component: AdminPrivacySection,
|
||||
parameters: {
|
||||
layout: "padded",
|
||||
msw: {
|
||||
handlers: [
|
||||
systemHandler({ enableAnalytics: true, googlevisibility: false }),
|
||||
metricsHandler({ enabled: true }),
|
||||
http.put("/api/v1/admin/settings", () => HttpResponse.json({})),
|
||||
http.put("/api/v1/admin/settings/section/privacy", () =>
|
||||
HttpResponse.json({}),
|
||||
),
|
||||
],
|
||||
},
|
||||
},
|
||||
decorators: [withProviders(true)],
|
||||
} satisfies Meta<typeof AdminPrivacySection>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
/** Telemetry on, search indexing off — the shipped defaults for a private instance. */
|
||||
export const Default: Story = {};
|
||||
|
||||
/** Everything opted out, the strictest configuration the section can express. */
|
||||
export const AllCollectionOff: Story = {
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [
|
||||
systemHandler({ enableAnalytics: false, googlevisibility: false }),
|
||||
metricsHandler({ enabled: false }),
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** While either settings request is in flight, a centred loader replaces the form. */
|
||||
export const Loading: Story = {
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [
|
||||
http.get("/api/v1/admin/settings/section/system", async () => {
|
||||
await delay("infinite");
|
||||
return HttpResponse.json({});
|
||||
}),
|
||||
metricsHandler({ enabled: false }),
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Restart-required edits on fields owned by both endpoints: the switches show
|
||||
* the pending values, each flagged with a pending badge.
|
||||
*/
|
||||
export const PendingChanges: Story = {
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [
|
||||
systemHandler({
|
||||
enableAnalytics: false,
|
||||
googlevisibility: false,
|
||||
_pending: { enableAnalytics: true, googlevisibility: true },
|
||||
}),
|
||||
metricsHandler({ enabled: false, _pending: { enabled: true } }),
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** Login mode disabled: nothing is fetched, the banner shows and the switches lock. */
|
||||
export const LoginDisabled: Story = {
|
||||
decorators: [withProviders(false)],
|
||||
};
|
||||
+12
@@ -181,6 +181,10 @@ export default function AdminPrivacySection() {
|
||||
</div>
|
||||
<Group gap="xs">
|
||||
<Switch
|
||||
aria-label={t(
|
||||
"admin.settings.privacy.enableAnalytics.label",
|
||||
"Enable Analytics",
|
||||
)}
|
||||
checked={settings?.enableAnalytics || false}
|
||||
onChange={(e) => {
|
||||
if (!loginEnabled) return;
|
||||
@@ -219,6 +223,10 @@ export default function AdminPrivacySection() {
|
||||
</div>
|
||||
<Group gap="xs">
|
||||
<Switch
|
||||
aria-label={t(
|
||||
"admin.settings.privacy.metricsEnabled.label",
|
||||
"Enable Metrics",
|
||||
)}
|
||||
checked={settings?.metricsEnabled || false}
|
||||
onChange={(e) => {
|
||||
if (!loginEnabled) return;
|
||||
@@ -269,6 +277,10 @@ export default function AdminPrivacySection() {
|
||||
</div>
|
||||
<Group gap="xs">
|
||||
<Switch
|
||||
aria-label={t(
|
||||
"admin.settings.privacy.googleVisibility.label",
|
||||
"Google Visibility",
|
||||
)}
|
||||
checked={settings?.googleVisibility || false}
|
||||
onChange={(e) => {
|
||||
if (!loginEnabled) return;
|
||||
|
||||
+173
@@ -0,0 +1,173 @@
|
||||
import type React from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { http, HttpResponse, delay } from "msw";
|
||||
import AdminStorageSharingSection from "@app/components/shared/config/configSections/AdminStorageSharingSection";
|
||||
import { AppConfigProvider } from "@app/contexts/AppConfigContext";
|
||||
import { UnsavedChangesProvider } from "@app/contexts/UnsavedChangesContext";
|
||||
|
||||
/**
|
||||
* Admin settings section for server-side file storage and the sharing options
|
||||
* built on top of it.
|
||||
*
|
||||
* The five switches form a dependency chain: storage gates sharing and group
|
||||
* signing, sharing gates share links and email sharing, and those last two carry
|
||||
* their own external prerequisites — a configured frontend URL for links, a
|
||||
* configured mail relay for email. Each unmet prerequisite both disables its
|
||||
* switch and adds an amber "requires…" note, so the interesting states are
|
||||
* points along that chain rather than variations of one payload.
|
||||
*
|
||||
* The settings are stitched from three sections — `storage` for the switches,
|
||||
* `system` for the frontend URL and `mail` for the relay — so every story mocks
|
||||
* all three; a missing one leaves the section stuck on its loader.
|
||||
*/
|
||||
function withProviders(enableLogin: boolean) {
|
||||
return function Decorator(Story: () => React.JSX.Element) {
|
||||
return (
|
||||
<AppConfigProvider
|
||||
autoFetch={false}
|
||||
bootstrapMode="non-blocking"
|
||||
initialConfig={{ enableLogin }}
|
||||
>
|
||||
<UnsavedChangesProvider>
|
||||
<Story />
|
||||
</UnsavedChangesProvider>
|
||||
</AppConfigProvider>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
function sectionHandlers({
|
||||
storage,
|
||||
frontendUrl = "",
|
||||
mailEnabled = false,
|
||||
}: {
|
||||
storage: Record<string, unknown>;
|
||||
frontendUrl?: string;
|
||||
mailEnabled?: boolean;
|
||||
}) {
|
||||
return [
|
||||
http.get("/api/v1/admin/settings/section/storage", () =>
|
||||
HttpResponse.json(storage),
|
||||
),
|
||||
http.get("/api/v1/admin/settings/section/system", () =>
|
||||
HttpResponse.json({ frontendUrl }),
|
||||
),
|
||||
http.get("/api/v1/admin/settings/section/mail", () =>
|
||||
HttpResponse.json({ enabled: mailEnabled }),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
const ALL_ON = {
|
||||
enabled: true,
|
||||
sharing: { enabled: true, linkEnabled: true, emailEnabled: true },
|
||||
signing: { enabled: true },
|
||||
};
|
||||
|
||||
const meta = {
|
||||
title: "Config/AdminStorageSharingSection",
|
||||
component: AdminStorageSharingSection,
|
||||
parameters: {
|
||||
layout: "padded",
|
||||
msw: {
|
||||
handlers: [
|
||||
...sectionHandlers({
|
||||
storage: ALL_ON,
|
||||
frontendUrl: "https://pdf.acme-legal.test",
|
||||
mailEnabled: true,
|
||||
}),
|
||||
http.put("/api/v1/admin/settings/section/storage", () =>
|
||||
HttpResponse.json({}),
|
||||
),
|
||||
http.put("/api/v1/admin/settings", () => HttpResponse.json({})),
|
||||
],
|
||||
},
|
||||
},
|
||||
decorators: [withProviders(true)],
|
||||
} satisfies Meta<typeof AdminStorageSharingSection>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
/** Every prerequisite met, so the full chain of switches is on and editable. */
|
||||
export const Default: Story = {};
|
||||
|
||||
/** Storage off — the switches that depend on it are disabled, whatever their stored value. */
|
||||
export const StorageDisabled: Story = {
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: sectionHandlers({
|
||||
storage: {
|
||||
enabled: false,
|
||||
sharing: { enabled: true, linkEnabled: true, emailEnabled: true },
|
||||
signing: { enabled: true },
|
||||
},
|
||||
frontendUrl: "https://pdf.acme-legal.test",
|
||||
mailEnabled: true,
|
||||
}),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Sharing is on but the frontend URL and mail relay are not configured: both
|
||||
* sharing channels are disabled and each shows its "requires…" note.
|
||||
*/
|
||||
export const PrerequisitesMissing: Story = {
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: sectionHandlers({
|
||||
storage: {
|
||||
enabled: true,
|
||||
sharing: { enabled: true, linkEnabled: false, emailEnabled: false },
|
||||
signing: { enabled: false },
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** While any of the three settings requests is in flight, a centred loader replaces the form. */
|
||||
export const Loading: Story = {
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [
|
||||
http.get("/api/v1/admin/settings/section/storage", async () => {
|
||||
await delay("infinite");
|
||||
return HttpResponse.json({});
|
||||
}),
|
||||
http.get("/api/v1/admin/settings/section/system", () =>
|
||||
HttpResponse.json({}),
|
||||
),
|
||||
http.get("/api/v1/admin/settings/section/mail", () =>
|
||||
HttpResponse.json({}),
|
||||
),
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** Restart-required edits: the pending switch positions show with pending badges. */
|
||||
export const PendingChanges: Story = {
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: sectionHandlers({
|
||||
storage: {
|
||||
enabled: true,
|
||||
sharing: { enabled: false, linkEnabled: false, emailEnabled: false },
|
||||
signing: { enabled: false },
|
||||
_pending: {
|
||||
sharing: { enabled: true, linkEnabled: true },
|
||||
signing: { enabled: true },
|
||||
},
|
||||
},
|
||||
frontendUrl: "https://pdf.acme-legal.test",
|
||||
mailEnabled: true,
|
||||
}),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** Login mode disabled: nothing is fetched, the banner shows and every switch locks. */
|
||||
export const LoginDisabled: Story = {
|
||||
decorators: [withProviders(false)],
|
||||
};
|
||||
+24
-4
@@ -183,6 +183,10 @@ export default function AdminStorageSharingSection() {
|
||||
</Text>
|
||||
</div>
|
||||
<Switch
|
||||
aria-label={t(
|
||||
"admin.settings.storage.enabled.label",
|
||||
"Enable Server File Storage",
|
||||
)}
|
||||
checked={storageEnabled}
|
||||
onChange={(e) =>
|
||||
setSettings({ ...settings, enabled: e.currentTarget.checked })
|
||||
@@ -216,6 +220,10 @@ export default function AdminStorageSharingSection() {
|
||||
</Text>
|
||||
</div>
|
||||
<Switch
|
||||
aria-label={t(
|
||||
"admin.settings.storage.sharing.enabled.label",
|
||||
"Enable Sharing",
|
||||
)}
|
||||
checked={settings.sharing?.enabled ?? false}
|
||||
onChange={(e) =>
|
||||
setSettings({
|
||||
@@ -254,7 +262,7 @@ export default function AdminStorageSharingSection() {
|
||||
)}
|
||||
</Text>
|
||||
{!frontendUrlConfigured && (
|
||||
<Text size="xs" c="orange">
|
||||
<Text size="xs" c="var(--color-amber-dark)">
|
||||
{t(
|
||||
"admin.settings.storage.sharing.links.frontendUrlNote",
|
||||
"Requires a Frontend URL. ",
|
||||
@@ -265,7 +273,7 @@ export default function AdminStorageSharingSection() {
|
||||
e.preventDefault();
|
||||
navigate("/settings/adminGeneral#frontendUrl");
|
||||
}}
|
||||
c="orange"
|
||||
c="var(--color-amber-dark)"
|
||||
td="underline"
|
||||
>
|
||||
{t(
|
||||
@@ -277,6 +285,10 @@ export default function AdminStorageSharingSection() {
|
||||
)}
|
||||
</div>
|
||||
<Switch
|
||||
aria-label={t(
|
||||
"admin.settings.storage.sharing.links.label",
|
||||
"Share links",
|
||||
)}
|
||||
checked={settings.sharing?.linkEnabled ?? false}
|
||||
onChange={(e) =>
|
||||
setSettings({
|
||||
@@ -317,7 +329,7 @@ export default function AdminStorageSharingSection() {
|
||||
)}
|
||||
</Text>
|
||||
{!mailEnabled && (
|
||||
<Text size="xs" c="orange">
|
||||
<Text size="xs" c="var(--color-amber-dark)">
|
||||
{t(
|
||||
"admin.settings.storage.sharing.email.mailNote",
|
||||
"Requires mail configuration. ",
|
||||
@@ -328,7 +340,7 @@ export default function AdminStorageSharingSection() {
|
||||
e.preventDefault();
|
||||
navigate("/settings/adminConnections");
|
||||
}}
|
||||
c="orange"
|
||||
c="var(--color-amber-dark)"
|
||||
td="underline"
|
||||
>
|
||||
{t(
|
||||
@@ -340,6 +352,10 @@ export default function AdminStorageSharingSection() {
|
||||
)}
|
||||
</div>
|
||||
<Switch
|
||||
aria-label={t(
|
||||
"admin.settings.storage.sharing.email.label",
|
||||
"Share by email",
|
||||
)}
|
||||
checked={settings.sharing?.emailEnabled ?? false}
|
||||
onChange={(e) =>
|
||||
setSettings({
|
||||
@@ -379,6 +395,10 @@ export default function AdminStorageSharingSection() {
|
||||
</Text>
|
||||
</div>
|
||||
<Switch
|
||||
aria-label={t(
|
||||
"admin.settings.storage.signing.enabled.label",
|
||||
"Enable Group Signing",
|
||||
)}
|
||||
checked={settings.signing?.enabled ?? false}
|
||||
onChange={(e) =>
|
||||
setSettings({
|
||||
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
import type React from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { http, HttpResponse, delay } from "msw";
|
||||
import AdminUsageSection from "@app/components/shared/config/configSections/AdminUsageSection";
|
||||
import { AppConfigProvider } from "@app/contexts/AppConfigContext";
|
||||
import type { EndpointStatisticsResponse } from "@app/services/usageAnalyticsService";
|
||||
|
||||
/**
|
||||
* Admin dashboard of endpoint usage: a chart and table of the busiest endpoints,
|
||||
* with controls for how many to show and whether to count API calls, UI calls or
|
||||
* both.
|
||||
*
|
||||
* The section takes no props. What it shows is decided by the licence: only an
|
||||
* ENTERPRISE licence with login mode on reaches the real statistics endpoint.
|
||||
* Anything short of that falls back to a built-in demo dataset, freezes the
|
||||
* controls and raises the enterprise banner — a deliberate teaser rather than an
|
||||
* empty state, so it is the state most admins actually see. Login and licence
|
||||
* both come from useAppConfig(), so each story sets them on the provider, and
|
||||
* only the licensed stories need the endpoint mocked at all.
|
||||
*/
|
||||
function withConfig(config: { enableLogin: boolean; license?: string }) {
|
||||
return function Decorator(Story: () => React.JSX.Element) {
|
||||
return (
|
||||
<AppConfigProvider
|
||||
autoFetch={false}
|
||||
bootstrapMode="non-blocking"
|
||||
initialConfig={config}
|
||||
>
|
||||
<Story />
|
||||
</AppConfigProvider>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
const STATISTICS: EndpointStatisticsResponse = {
|
||||
totalVisits: 4820,
|
||||
totalEndpoints: 6,
|
||||
endpoints: [
|
||||
{ endpoint: "merge-pdfs", visits: 1640, percentage: 34.0 },
|
||||
{ endpoint: "compress-pdf", visits: 1120, percentage: 23.2 },
|
||||
{ endpoint: "sign", visits: 880, percentage: 18.3 },
|
||||
{ endpoint: "ocr-pdf", visits: 540, percentage: 11.2 },
|
||||
{ endpoint: "add-watermark", visits: 380, percentage: 7.9 },
|
||||
{ endpoint: "split-pages", visits: 260, percentage: 5.4 },
|
||||
],
|
||||
};
|
||||
|
||||
const STATISTICS_PATH = "/api/v1/proprietary/ui-data/usage-endpoint-statistics";
|
||||
|
||||
const meta = {
|
||||
title: "Config/AdminUsageSection",
|
||||
component: AdminUsageSection,
|
||||
parameters: {
|
||||
layout: "padded",
|
||||
msw: {
|
||||
handlers: [http.get(STATISTICS_PATH, () => HttpResponse.json(STATISTICS))],
|
||||
},
|
||||
},
|
||||
decorators: [withConfig({ enableLogin: true })],
|
||||
} satisfies Meta<typeof AdminUsageSection>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
/** No enterprise licence: demo figures behind the enterprise banner, controls frozen. */
|
||||
export const Default: Story = {};
|
||||
|
||||
/** Login mode off as well, so both the login and enterprise banners are raised. */
|
||||
export const LoginDisabled: Story = {
|
||||
decorators: [withConfig({ enableLogin: false })],
|
||||
};
|
||||
|
||||
/**
|
||||
* Licensed and logged in: real statistics, live controls, and the explanatory
|
||||
* banner linking through to the audit dashboard.
|
||||
*/
|
||||
export const EnterpriseLicensed: Story = {
|
||||
decorators: [withConfig({ enableLogin: true, license: "ENTERPRISE" })],
|
||||
};
|
||||
|
||||
/** While the statistics request is in flight, a centred loader replaces the dashboard. */
|
||||
export const Loading: Story = {
|
||||
decorators: [withConfig({ enableLogin: true, license: "ENTERPRISE" })],
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [
|
||||
http.get(STATISTICS_PATH, async () => {
|
||||
await delay("infinite");
|
||||
return HttpResponse.json(STATISTICS);
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** The statistics request failed: an error alert replaces the dashboard entirely. */
|
||||
export const LoadError: Story = {
|
||||
decorators: [withConfig({ enableLogin: true, license: "ENTERPRISE" })],
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [
|
||||
http.get(STATISTICS_PATH, () =>
|
||||
HttpResponse.json(null, { status: 500 }),
|
||||
),
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** Licensed, but the instance has recorded no traffic yet. */
|
||||
export const NoTrafficRecorded: Story = {
|
||||
decorators: [withConfig({ enableLogin: true, license: "ENTERPRISE" })],
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [
|
||||
http.get(STATISTICS_PATH, () =>
|
||||
HttpResponse.json({
|
||||
totalVisits: 0,
|
||||
totalEndpoints: 0,
|
||||
endpoints: [],
|
||||
} satisfies EndpointStatisticsResponse),
|
||||
),
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
+1
-1
@@ -269,7 +269,7 @@ export default function LoginAgreementEditor({
|
||||
|
||||
{loading && <Loader size="xs" />}
|
||||
{loadFailed && !loading && (
|
||||
<Text size="xs" c="red">
|
||||
<Text size="xs" c="var(--color-red-dark)">
|
||||
{t(
|
||||
"admin.settings.legal.loginAgreement.loadError",
|
||||
"Failed to load the agreement for {{locale}}. Switch language and back to retry.",
|
||||
|
||||
+10
-4
@@ -350,7 +350,7 @@ export default function TeamDetailsSection({
|
||||
if (!team) {
|
||||
return (
|
||||
<Stack align="center" py="xl">
|
||||
<Text size="sm" c="red">
|
||||
<Text size="sm" c="var(--color-red-dark)">
|
||||
{t("workspace.teams.teamNotFound", "Team not found")}
|
||||
</Text>
|
||||
<Button variant="secondary" onClick={onBack}>
|
||||
@@ -417,7 +417,11 @@ export default function TeamDetailsSection({
|
||||
<Table.Th style={{ fontWeight: 600 }} fz="sm" w={100}>
|
||||
{t("workspace.people.role")}
|
||||
</Table.Th>
|
||||
<Table.Th w={50}></Table.Th>
|
||||
<Table.Th w={50}>
|
||||
<span className="sr-only">
|
||||
{t("workspace.people.memberActions", "Member actions")}
|
||||
</span>
|
||||
</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
@@ -480,7 +484,9 @@ export default function TeamDetailsSection({
|
||||
maw={200}
|
||||
style={{
|
||||
lineHeight: 1.3,
|
||||
opacity: user.enabled ? 1 : 0.6,
|
||||
color: user.enabled
|
||||
? undefined
|
||||
: "var(--c-text-muted)",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
@@ -749,7 +755,7 @@ export default function TeamDetailsSection({
|
||||
availableUsersForTeam.find(
|
||||
(u) => u.id.toString() === selectedUserId,
|
||||
)?.team && (
|
||||
<Text size="xs" c="orange">
|
||||
<Text size="xs" c="var(--color-amber-dark)">
|
||||
{t("workspace.teams.addMemberToTeam.willBeMoved")}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
+6
-2
@@ -317,7 +317,11 @@ export default function TeamsSection() {
|
||||
>
|
||||
{t("workspace.teams.totalMembers")}
|
||||
</Table.Th>
|
||||
<Table.Th style={{ width: 50 }}></Table.Th>
|
||||
<Table.Th style={{ width: 50 }}>
|
||||
<span className="sr-only">
|
||||
{t("workspace.teams.teamActions", "Team actions")}
|
||||
</span>
|
||||
</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
@@ -631,7 +635,7 @@ export default function TeamsSection() {
|
||||
availableUsersForSelectedTeam.find(
|
||||
(u) => u.id.toString() === selectedUserId,
|
||||
)?.team && (
|
||||
<Text size="xs" c="orange">
|
||||
<Text size="xs" c="var(--color-amber-dark)">
|
||||
{t("workspace.teams.addMemberToTeam.willBeMoved")}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@ export default function RefreshModal({
|
||||
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="red">
|
||||
<Text size="sm" c="var(--color-red-dark)">
|
||||
{t(
|
||||
"config.apiKeys.refreshModal.warning",
|
||||
"⚠️ Warning: This action will generate new API keys and make your previous keys invalid.",
|
||||
|
||||
-2
@@ -92,7 +92,6 @@ const AuditSystemStatus: React.FC<AuditSystemStatusProps> = ({ status }) => {
|
||||
style={{
|
||||
marginLeft: "0.5rem",
|
||||
fontSize: "0.75rem",
|
||||
opacity: 0.7,
|
||||
}}
|
||||
>
|
||||
(
|
||||
@@ -115,7 +114,6 @@ const AuditSystemStatus: React.FC<AuditSystemStatusProps> = ({ status }) => {
|
||||
style={{
|
||||
marginLeft: "0.5rem",
|
||||
fontSize: "0.75rem",
|
||||
opacity: 0.7,
|
||||
}}
|
||||
>
|
||||
(
|
||||
|
||||
+1
@@ -89,6 +89,7 @@ const AvailablePlansSection: React.FC<AvailablePlansSectionProps> = ({
|
||||
</div>
|
||||
{currency && onCurrencyChange && currencyOptions && (
|
||||
<Select
|
||||
aria-label={t("plan.availablePlans.currency", "Billing currency")}
|
||||
value={currency}
|
||||
onChange={(value) => onCurrencyChange(value || "usd")}
|
||||
data={currencyOptions}
|
||||
|
||||
+1
-1
@@ -85,7 +85,7 @@ const FeatureComparisonTable: React.FC<FeatureComparisonTableProps> = ({
|
||||
style={{ textAlign: "center", padding: "0.75rem" }}
|
||||
>
|
||||
{plan.features[featureIndex]?.included ? (
|
||||
<Text c="green" fw={600} size="lg">
|
||||
<Text c="var(--color-green-dark)" fw={600} size="lg">
|
||||
✓
|
||||
</Text>
|
||||
) : (
|
||||
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
import type React from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { within, userEvent } from "storybook/test";
|
||||
import LicenseKeySection from "@app/components/shared/config/configSections/plan/LicenseKeySection";
|
||||
import type { LicenseInfo } from "@app/services/licenseService";
|
||||
import { AppConfigProvider } from "@app/contexts/AppConfigContext";
|
||||
import { LicenseProvider } from "@app/contexts/LicenseContext";
|
||||
|
||||
/**
|
||||
* The collapsible panel at the foot of the plan page for activating a licence
|
||||
* bought outside the in-app checkout, either as a key string or a certificate
|
||||
* file.
|
||||
*
|
||||
* Everything below the toggle lives behind local state — the collapse itself and
|
||||
* the key/file choice — so the interesting states are only reachable by driving
|
||||
* the control rather than by setting a prop. Each story below opens the panel
|
||||
* first for that reason. The toggle is the only button while collapsed, so it is
|
||||
* found by role without depending on the translated copy.
|
||||
*
|
||||
* A licence already installed adds two alerts above the form: an overwrite
|
||||
* warning and a summary of what is currently active, which reads differently for
|
||||
* a key than for a file path.
|
||||
*
|
||||
* useLicense() throws outside a provider. LicenseProvider is mounted with a
|
||||
* non-admin config so it settles without issuing a licence request of its own.
|
||||
*/
|
||||
function withLicenseContext(enableLogin: boolean) {
|
||||
return function Decorator(Story: () => React.JSX.Element) {
|
||||
return (
|
||||
<AppConfigProvider
|
||||
autoFetch={false}
|
||||
bootstrapMode="non-blocking"
|
||||
initialConfig={{ enableLogin }}
|
||||
>
|
||||
<LicenseProvider>
|
||||
<Story />
|
||||
</LicenseProvider>
|
||||
</AppConfigProvider>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
async function openPanel(canvasElement: HTMLElement) {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(canvas.getByRole("button"));
|
||||
}
|
||||
|
||||
const keyLicence: LicenseInfo = {
|
||||
licenseType: "SERVER",
|
||||
enabled: true,
|
||||
maxUsers: 0,
|
||||
hasKey: true,
|
||||
licenseKey: "STORY-LICENCE-0000-0000-0000",
|
||||
};
|
||||
|
||||
const fileLicence: LicenseInfo = {
|
||||
licenseType: "ENTERPRISE",
|
||||
enabled: true,
|
||||
maxUsers: 250,
|
||||
hasKey: true,
|
||||
licenseKey: "file:/opt/stirling/licence.cert",
|
||||
};
|
||||
|
||||
const meta = {
|
||||
title: "Config/Plan/LicenseKeySection",
|
||||
component: LicenseKeySection,
|
||||
parameters: { layout: "padded" },
|
||||
decorators: [withLicenseContext(true)],
|
||||
} satisfies Meta<typeof LicenseKeySection>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
/** Closed, which is how the panel first appears beneath the plan cards. */
|
||||
export const Collapsed: Story = {};
|
||||
|
||||
/** Opened with no licence installed: just the explanatory alert and the key field. */
|
||||
export const Expanded: Story = {
|
||||
play: ({ canvasElement }) => openPanel(canvasElement),
|
||||
};
|
||||
|
||||
/** The certificate-file alternative, which swaps the key field for a file picker. */
|
||||
export const CertificateFileUpload: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await openPanel(canvasElement);
|
||||
const canvas = within(canvasElement);
|
||||
// The key/file choice is a radiogroup; the second option is the file upload.
|
||||
await userEvent.click(canvas.getAllByRole("radio")[1]);
|
||||
},
|
||||
};
|
||||
|
||||
/** A key licence is already active: the overwrite warning and the active-licence summary appear. */
|
||||
export const ExistingKeyLicence: Story = {
|
||||
args: { currentLicenseInfo: keyLicence },
|
||||
play: ({ canvasElement }) => openPanel(canvasElement),
|
||||
};
|
||||
|
||||
/** The active licence came from a certificate file, so its summary names the path instead. */
|
||||
export const ExistingFileLicence: Story = {
|
||||
args: { currentLicenseInfo: fileLicence },
|
||||
play: ({ canvasElement }) => openPanel(canvasElement),
|
||||
};
|
||||
|
||||
/** Login mode disabled: the panel still opens but every input and the save action are locked. */
|
||||
export const LoginDisabled: Story = {
|
||||
decorators: [withLicenseContext(false)],
|
||||
play: ({ canvasElement }) => openPanel(canvasElement),
|
||||
};
|
||||
+1
-1
@@ -176,7 +176,7 @@ const PlanCard: React.FC<PlanCardProps> = ({
|
||||
isCurrentTier &&
|
||||
currentLicenseInfo &&
|
||||
currentLicenseInfo.maxUsers > 0 && (
|
||||
<Text size="sm" c="green" fw={500} ta="center">
|
||||
<Text size="sm" c="var(--color-green-dark)" fw={500} ta="center">
|
||||
{t("plan.licensedSeats", "Licensed: {{count}} seats", {
|
||||
count: currentLicenseInfo.maxUsers,
|
||||
})}
|
||||
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
import type React from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { within, userEvent } from "storybook/test";
|
||||
import StaticCheckoutModal from "@app/components/shared/config/configSections/plan/StaticCheckoutModal";
|
||||
import { AppConfigProvider } from "@app/contexts/AppConfigContext";
|
||||
import { LicenseProvider } from "@app/contexts/LicenseContext";
|
||||
|
||||
/**
|
||||
* The checkout used when Stripe is not wired into the app: it collects an email,
|
||||
* sends the buyer to a static Stripe payment link in a new tab, then waits for
|
||||
* them to paste back the licence key that arrives by email.
|
||||
*
|
||||
* The modal walks three stages held in local state (email → billing period →
|
||||
* licence activation), so only the first is reachable by props alone; the later
|
||||
* ones are driven through the form. `planName` and `isUpgrade` change nothing
|
||||
* but the heading — either one pointing at Enterprise produces the same title —
|
||||
* so there is one story per distinct heading rather than one per prop.
|
||||
*
|
||||
* Mantine renders the modal into a portal outside the story canvas, so the
|
||||
* queries below run against the document body.
|
||||
*
|
||||
* useLicense() throws outside a provider; LicenseProvider is mounted with a
|
||||
* non-admin config so it settles without issuing a licence request of its own.
|
||||
*/
|
||||
function withLicenseContext(Story: () => React.JSX.Element) {
|
||||
return (
|
||||
<AppConfigProvider
|
||||
autoFetch={false}
|
||||
bootstrapMode="non-blocking"
|
||||
initialConfig={{ enableLogin: true }}
|
||||
>
|
||||
<LicenseProvider>
|
||||
<Story />
|
||||
</LicenseProvider>
|
||||
</AppConfigProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: "Config/Plan/StaticCheckoutModal",
|
||||
component: StaticCheckoutModal,
|
||||
parameters: { layout: "centered" },
|
||||
args: {
|
||||
opened: true,
|
||||
onClose: () => {},
|
||||
planName: "server",
|
||||
},
|
||||
decorators: [withLicenseContext],
|
||||
} satisfies Meta<typeof StaticCheckoutModal>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
/** Buying a Server licence from the free tier — the opening email step. */
|
||||
export const ServerLicence: Story = {};
|
||||
|
||||
/** Moving up to Enterprise, which reframes the same email step as an upgrade. */
|
||||
export const EnterpriseUpgrade: Story = {
|
||||
args: { planName: "enterprise" },
|
||||
};
|
||||
|
||||
/** A malformed address is rejected in place rather than advancing the flow. */
|
||||
export const InvalidEmail: Story = {
|
||||
play: async () => {
|
||||
const body = within(document.body);
|
||||
await userEvent.type(body.getByRole("textbox"), "not-an-email{enter}");
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Past the email step: the monthly/yearly choice, each option opening the
|
||||
* matching Stripe payment link.
|
||||
*/
|
||||
export const BillingPeriodChoice: Story = {
|
||||
play: async () => {
|
||||
const body = within(document.body);
|
||||
await userEvent.type(
|
||||
body.getByRole("textbox"),
|
||||
"jane@acme-legal.test{enter}",
|
||||
);
|
||||
},
|
||||
};
|
||||
+8
-3
@@ -15,7 +15,10 @@ import { useTranslation } from "react-i18next";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import { EmailStage } from "@app/components/shared/stripeCheckout/stages/EmailStage";
|
||||
import { validateEmail } from "@app/components/shared/stripeCheckout/utils/checkoutUtils";
|
||||
import { getClickablePaperStyle } from "@app/components/shared/stripeCheckout/utils/cardStyles";
|
||||
import {
|
||||
getClickableCardProps,
|
||||
getClickablePaperStyle,
|
||||
} from "@app/components/shared/stripeCheckout/utils/cardStyles";
|
||||
import {
|
||||
STATIC_STRIPE_LINKS,
|
||||
buildStripeUrlWithEmail,
|
||||
@@ -194,7 +197,9 @@ const StaticCheckoutModal: React.FC<StaticCheckoutModalProps> = ({
|
||||
p="xl"
|
||||
radius="md"
|
||||
style={getClickablePaperStyle()}
|
||||
onClick={() => handlePeriodSelect("monthly")}
|
||||
{...getClickableCardProps(() =>
|
||||
handlePeriodSelect("monthly"),
|
||||
)}
|
||||
>
|
||||
<Stack
|
||||
gap="md"
|
||||
@@ -218,7 +223,7 @@ const StaticCheckoutModal: React.FC<StaticCheckoutModalProps> = ({
|
||||
p="xl"
|
||||
radius="md"
|
||||
style={getClickablePaperStyle()}
|
||||
onClick={() => handlePeriodSelect("yearly")}
|
||||
{...getClickableCardProps(() => handlePeriodSelect("yearly"))}
|
||||
>
|
||||
<Stack
|
||||
gap="md"
|
||||
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
import type React from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import StaticPlanSection from "@app/components/shared/config/configSections/plan/StaticPlanSection";
|
||||
import type { LicenseInfo } from "@app/services/licenseService";
|
||||
import { AppConfigProvider } from "@app/contexts/AppConfigContext";
|
||||
import { LicenseProvider } from "@app/contexts/LicenseContext";
|
||||
|
||||
/**
|
||||
* The plan page shown when Stripe-backed checkout is unavailable — no Supabase
|
||||
* configuration, or the live plans request failed. It lists the three tiers from
|
||||
* hardcoded copy rather than fetched pricing, so the only thing that changes
|
||||
* what it renders is the licence it is handed.
|
||||
*
|
||||
* Each card's action is derived from that licence's tier: the current tier gets
|
||||
* "Manage", anything below it collapses to "Included", and Enterprise stays
|
||||
* blocked until a Server licence exists. The stories below walk that ladder,
|
||||
* since the button logic is the substance of the component.
|
||||
*
|
||||
* The embedded licence-key panel and checkout modal both call useLicense(),
|
||||
* which throws outside a provider. LicenseProvider is mounted with a non-admin
|
||||
* config so it settles without issuing a licence request of its own — the tier
|
||||
* on screen comes from the prop, not from the context.
|
||||
*/
|
||||
function withLicenseContext(Story: () => React.JSX.Element) {
|
||||
return (
|
||||
<AppConfigProvider
|
||||
autoFetch={false}
|
||||
bootstrapMode="non-blocking"
|
||||
initialConfig={{ enableLogin: true }}
|
||||
>
|
||||
<LicenseProvider>
|
||||
<Story />
|
||||
</LicenseProvider>
|
||||
</AppConfigProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const freeLicence: LicenseInfo = {
|
||||
licenseType: "NORMAL",
|
||||
enabled: false,
|
||||
maxUsers: 5,
|
||||
hasKey: false,
|
||||
};
|
||||
|
||||
const serverLicence: LicenseInfo = {
|
||||
licenseType: "SERVER",
|
||||
enabled: true,
|
||||
maxUsers: 0,
|
||||
hasKey: true,
|
||||
licenseKey: "STORY-LICENCE-2222-2222-2222",
|
||||
};
|
||||
|
||||
const enterpriseLicence: LicenseInfo = {
|
||||
licenseType: "ENTERPRISE",
|
||||
enabled: true,
|
||||
maxUsers: 250,
|
||||
hasKey: true,
|
||||
licenseKey: "file:/opt/stirling/licence.cert",
|
||||
};
|
||||
|
||||
const meta = {
|
||||
title: "Config/Plan/StaticPlanSection",
|
||||
component: StaticPlanSection,
|
||||
parameters: { layout: "padded" },
|
||||
decorators: [withLicenseContext],
|
||||
} satisfies Meta<typeof StaticPlanSection>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
/** Free tier: Free is the current plan, Server offers an upgrade, Enterprise is gated behind it. */
|
||||
export const FreeTier: Story = {
|
||||
args: { currentLicenseInfo: freeLicence },
|
||||
};
|
||||
|
||||
/** Server licence: Free collapses to "Included", Server offers billing management, Enterprise asks for contact. */
|
||||
export const ServerTier: Story = {
|
||||
args: { currentLicenseInfo: serverLicence },
|
||||
};
|
||||
|
||||
/** Enterprise licence: both lower tiers collapse to "Included" and only Enterprise is manageable. */
|
||||
export const EnterpriseTier: Story = {
|
||||
args: { currentLicenseInfo: enterpriseLicence },
|
||||
};
|
||||
|
||||
/**
|
||||
* Licence details not supplied at all — the tier is indeterminate, so Free is
|
||||
* marked current but the paid cards resolve to no action rather than an
|
||||
* upgrade path. Worth keeping visible: it is what an admin briefly sees while
|
||||
* licence info is still resolving.
|
||||
*/
|
||||
export const LicenceUnknown: Story = {};
|
||||
+1
-1
@@ -17,7 +17,7 @@
|
||||
|
||||
.text-divider .text-divider__label {
|
||||
color: rgb(
|
||||
var(--text-divider-label-rgb, var(--gray-400)) /
|
||||
var(--text-divider-label-rgb, var(--gray-600)) /
|
||||
var(--text-divider-opacity, 1)
|
||||
);
|
||||
font-size: 0.75rem; /* 12px */
|
||||
|
||||
+6
-2
@@ -80,7 +80,11 @@ export const PlanSelectionStage: React.FC<PlanSelectionStageProps> = ({
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: "auto", paddingTop: "1rem" }}>
|
||||
<Button variant="secondary" fullWidth>
|
||||
<Button
|
||||
variant="secondary"
|
||||
fullWidth
|
||||
onClick={() => onSelectPlan("monthly")}
|
||||
>
|
||||
{t("payment.planStage.selectMonthly", "Select Monthly")}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -193,7 +197,7 @@ export const PlanSelectionStage: React.FC<PlanSelectionStageProps> = ({
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: "auto", paddingTop: "1rem" }}>
|
||||
<Button fullWidth>
|
||||
<Button fullWidth onClick={() => onSelectPlan("yearly")}>
|
||||
{t("payment.planStage.selectYearly", "Select Yearly")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
+20
-1
@@ -1,4 +1,4 @@
|
||||
import { CSSProperties } from "react";
|
||||
import { CSSProperties, KeyboardEvent } from "react";
|
||||
|
||||
/**
|
||||
* Shared styling utilities for plan cards
|
||||
@@ -46,3 +46,22 @@ export function getClickablePaperStyle(
|
||||
...getCardBorderStyle(isHighlighted),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Semantics for a card that is itself the control. `getClickablePaperStyle`
|
||||
* only makes a card *look* clickable; without these a Paper with an onClick is
|
||||
* a div, so the choice cannot be reached or made by keyboard.
|
||||
*/
|
||||
export function getClickableCardProps(onActivate: () => void) {
|
||||
return {
|
||||
role: "button",
|
||||
tabIndex: 0,
|
||||
onClick: onActivate,
|
||||
onKeyDown: (e: KeyboardEvent<HTMLElement>) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onActivate();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -406,7 +406,7 @@ const ParticipantView: React.FC<ParticipantViewProps> = ({ token }) => {
|
||||
{certValidation.status === "valid" && (
|
||||
<Text
|
||||
size="sm"
|
||||
c="green"
|
||||
c="var(--color-green-dark)"
|
||||
data-testid="cert-validation-feedback"
|
||||
>
|
||||
{t(
|
||||
@@ -432,7 +432,7 @@ const ParticipantView: React.FC<ParticipantViewProps> = ({ token }) => {
|
||||
{certValidation.status === "error" && (
|
||||
<Text
|
||||
size="sm"
|
||||
c="red"
|
||||
c="var(--color-red-dark)"
|
||||
data-testid="cert-validation-feedback"
|
||||
>
|
||||
{t(
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import AuthCallback from "@app/routes/AuthCallback";
|
||||
import "@app/auth/ui/auth.css";
|
||||
|
||||
/**
|
||||
* The landing screen an OAuth or SSO provider redirects back to. It has a
|
||||
* single visual state — a branded "completing authentication" card with a
|
||||
* spinner — because every outcome of the token exchange resolves by
|
||||
* navigating elsewhere: on to the requested page, or back to the login screen
|
||||
* carrying an error. Only the waiting moment is ever rendered here.
|
||||
*
|
||||
* With no token in the URL fragment the route redirects immediately, so the
|
||||
* story shows the card as the user sees it during a real exchange.
|
||||
*/
|
||||
const meta: Meta<typeof AuthCallback> = {
|
||||
title: "Auth/Auth Callback",
|
||||
component: AuthCallback,
|
||||
parameters: { layout: "fullscreen" },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof AuthCallback>;
|
||||
|
||||
export const Default: Story = {};
|
||||
@@ -469,7 +469,7 @@ export default function Login() {
|
||||
border:
|
||||
"1px solid color-mix(in srgb, var(--c-success) 30%, transparent)",
|
||||
borderRadius: "0.5rem",
|
||||
color: "var(--c-success)",
|
||||
color: "var(--color-green-dark)",
|
||||
}}
|
||||
>
|
||||
<p
|
||||
|
||||
@@ -135,7 +135,7 @@ export default function Signup() {
|
||||
variant="tertiary"
|
||||
onClick={() => navigate("/login")}
|
||||
className="auth-link-black"
|
||||
style={{ color: "var(--c-primary)" }}
|
||||
style={{ color: "var(--c-accent-text)" }}
|
||||
>
|
||||
{t("login.logIn", "Log In")}
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import AuthLayout from "@app/routes/authShared/AuthLayout";
|
||||
import LoginHeader from "@app/routes/login/LoginHeader";
|
||||
import "@app/auth/ui/auth.css";
|
||||
|
||||
/**
|
||||
* The editor's wrapper around the shared auth card: the same centred shell the
|
||||
* portal uses, with the editor's legal and cookie footer pinned to the bottom
|
||||
* of the viewport. It takes no props beyond its children, so what varies
|
||||
* between stories is the height of the content sitting inside the card and how
|
||||
* that content sits against the fixed footer.
|
||||
*/
|
||||
const meta: Meta<typeof AuthLayout> = {
|
||||
title: "Auth/Auth Layout",
|
||||
component: AuthLayout,
|
||||
parameters: { layout: "fullscreen" },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof AuthLayout>;
|
||||
|
||||
/** A short screen — the card floats well clear of the footer. */
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
children: (
|
||||
<LoginHeader
|
||||
title="Sign in"
|
||||
subtitle="Enter your credentials to continue."
|
||||
/>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* A tall screen: the card grows towards the fixed footer, which is the case
|
||||
* that shows whether the two collide on short viewports.
|
||||
*/
|
||||
export const TallContent: Story = {
|
||||
args: {
|
||||
children: (
|
||||
<>
|
||||
<LoginHeader title="Create your account" />
|
||||
{Array.from({ length: 8 }, (_, i) => (
|
||||
<p key={i} style={{ color: "var(--c-text)" }}>
|
||||
Placeholder form row {i + 1}
|
||||
</p>
|
||||
))}
|
||||
</>
|
||||
),
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { AuthContext } from "@app/auth/context";
|
||||
import type { AuthContextValue, AuthUser } from "@app/auth/types";
|
||||
import LoggedInState from "@app/routes/login/LoggedInState";
|
||||
|
||||
/**
|
||||
* The interstitial the login route shows when someone who already has a
|
||||
* session lands on it: a confirmation card naming the signed-in address,
|
||||
* which redirects to the workspace a couple of seconds later.
|
||||
*
|
||||
* It reads only the user off the auth context, so the stories supply a slice
|
||||
* of that context rather than mounting a real provider. The redirect timer
|
||||
* still runs; in Storybook there is nowhere to navigate to, so the card stays
|
||||
* on screen.
|
||||
*/
|
||||
|
||||
/** Minimal auth context slice — only `user` is read by this screen. */
|
||||
function authValue(user: AuthUser | null): AuthContextValue {
|
||||
return {
|
||||
session: null,
|
||||
user,
|
||||
displayName: user?.username ?? null,
|
||||
isAnonymous: false,
|
||||
isAdmin: false,
|
||||
portalAccess: false,
|
||||
role: user?.role ?? null,
|
||||
loading: false,
|
||||
error: null,
|
||||
signOut: async () => {},
|
||||
refreshSession: async () => {},
|
||||
};
|
||||
}
|
||||
|
||||
const signedInUser: AuthUser = {
|
||||
id: "1",
|
||||
email: "ada@example.com",
|
||||
username: "ada",
|
||||
role: "USER",
|
||||
};
|
||||
|
||||
const meta: Meta<typeof LoggedInState> = {
|
||||
title: "Auth/Logged In State",
|
||||
component: LoggedInState,
|
||||
parameters: { layout: "fullscreen" },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof LoggedInState>;
|
||||
|
||||
export const Default: Story = {
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<AuthContext.Provider value={authValue(signedInUser)}>
|
||||
<Story />
|
||||
</AuthContext.Provider>
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Sessions without an email address — anonymous or SSO users the backend
|
||||
* returns no address for — leave the label with nothing after the colon.
|
||||
*/
|
||||
export const WithoutEmail: Story = {
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<AuthContext.Provider value={authValue({ ...signedInUser, email: "" })}>
|
||||
<Story />
|
||||
</AuthContext.Provider>
|
||||
),
|
||||
],
|
||||
};
|
||||
@@ -55,7 +55,7 @@ export default function LoggedInState() {
|
||||
style={{
|
||||
fontSize: "24px",
|
||||
fontWeight: "bold",
|
||||
color: "var(--c-success)",
|
||||
color: "var(--color-green-dark)",
|
||||
marginBottom: "8px",
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import LoginHeader from "@app/routes/login/LoginHeader";
|
||||
import "@app/auth/ui/auth.css";
|
||||
|
||||
/**
|
||||
* The wordmark-and-title block that opens every auth screen. The wordmark
|
||||
* variant is resolved from the user's logo preference and the active colour
|
||||
* scheme, so it needs no props; what callers vary is the copy (title, optional
|
||||
* subtitle) and whether the block is centred.
|
||||
*/
|
||||
const meta: Meta<typeof LoginHeader> = {
|
||||
title: "Auth/Login Header",
|
||||
component: LoginHeader,
|
||||
parameters: { layout: "centered" },
|
||||
args: {
|
||||
title: "Sign in to Stirling PDF",
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div style={{ width: 360 }}>
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof LoginHeader>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
/** Screens that need a line of guidance beneath the title pass a subtitle. */
|
||||
export const WithSubtitle: Story = {
|
||||
args: {
|
||||
subtitle: "Enter your credentials to continue.",
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Centred layout, used where the header stands alone rather than above a
|
||||
* left-aligned form — status and callback screens, for instance.
|
||||
*/
|
||||
export const Centered: Story = {
|
||||
args: {
|
||||
subtitle: "Enter your credentials to continue.",
|
||||
centerOnly: true,
|
||||
},
|
||||
};
|
||||
|
||||
/** Long titles wrap within the card instead of widening it. */
|
||||
export const LongTitle: Story = {
|
||||
args: {
|
||||
title: "Sign in to continue to your organisation's document workspace",
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import NavigationLink from "@app/routes/login/NavigationLink";
|
||||
import "@app/auth/ui/auth.css";
|
||||
|
||||
/**
|
||||
* The quiet text link the auth screens use to move between login, signup and
|
||||
* password reset. Its only variation is the disabled state, which the screens
|
||||
* apply while a submission is in flight so the user cannot navigate away
|
||||
* mid-request.
|
||||
*/
|
||||
const meta: Meta<typeof NavigationLink> = {
|
||||
title: "Auth/Navigation Link",
|
||||
component: NavigationLink,
|
||||
parameters: { layout: "centered" },
|
||||
args: {
|
||||
text: "Back to login",
|
||||
onClick: () => {},
|
||||
},
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof NavigationLink>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
/** Held inert while the surrounding form is submitting. */
|
||||
export const Disabled: Story = {
|
||||
args: { isDisabled: true },
|
||||
};
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import SignupForm from "@app/routes/signup/SignupForm";
|
||||
import "@app/auth/ui/auth.css";
|
||||
|
||||
/**
|
||||
* The account-creation form body. Three things change what it shows: the
|
||||
* password field (the confirmation input stays collapsed until the password
|
||||
* reaches four characters), and the `showName` / `showTerms` flags, which the
|
||||
* cloud signup turns on and the self-hosted one leaves off.
|
||||
*
|
||||
* The form is fully controlled, so the stories pass fixed values and no-op
|
||||
* setters — each story pins one state rather than exposing a live form.
|
||||
*/
|
||||
const meta: Meta<typeof SignupForm> = {
|
||||
title: "Auth/Signup Form",
|
||||
component: SignupForm,
|
||||
parameters: { layout: "centered" },
|
||||
args: {
|
||||
email: "",
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
setEmail: () => {},
|
||||
setPassword: () => {},
|
||||
setConfirmPassword: () => {},
|
||||
onSubmit: () => {},
|
||||
isSubmitting: false,
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div style={{ width: 360 }}>
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof SignupForm>;
|
||||
|
||||
/** Untouched form: confirmation collapsed, submit disabled. */
|
||||
export const Default: Story = {};
|
||||
|
||||
/**
|
||||
* Past the four-character threshold the confirmation field animates open, so
|
||||
* the form grows a row without the layout jumping.
|
||||
*/
|
||||
export const ConfirmationRevealed: Story = {
|
||||
args: {
|
||||
email: "ada@example.com",
|
||||
password: "hunter2",
|
||||
confirmPassword: "hunter2",
|
||||
},
|
||||
};
|
||||
|
||||
/** The cloud signup additionally collects a name and terms acceptance. */
|
||||
export const WithNameAndTerms: Story = {
|
||||
args: {
|
||||
name: "Ada Lovelace",
|
||||
email: "ada@example.com",
|
||||
password: "hunter2",
|
||||
confirmPassword: "hunter2",
|
||||
setName: () => {},
|
||||
setAgree: () => {},
|
||||
showName: true,
|
||||
showTerms: true,
|
||||
},
|
||||
};
|
||||
|
||||
/** Terms presented but not yet accepted, which holds the submit disabled. */
|
||||
export const TermsNotAccepted: Story = {
|
||||
args: {
|
||||
email: "ada@example.com",
|
||||
password: "hunter2",
|
||||
confirmPassword: "hunter2",
|
||||
agree: false,
|
||||
setAgree: () => {},
|
||||
showTerms: true,
|
||||
},
|
||||
};
|
||||
|
||||
/** Server-side validation returned per-field messages. */
|
||||
export const WithFieldErrors: Story = {
|
||||
args: {
|
||||
name: "Ada Lovelace",
|
||||
email: "not-an-email",
|
||||
password: "short",
|
||||
confirmPassword: "shore",
|
||||
setName: () => {},
|
||||
showName: true,
|
||||
fieldErrors: {
|
||||
name: "Please enter your name.",
|
||||
email: "Enter a valid email address.",
|
||||
password: "Password must be at least 8 characters.",
|
||||
confirmPassword: "Passwords do not match.",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** In flight: the submit button takes over as the progress indicator. */
|
||||
export const Submitting: Story = {
|
||||
args: {
|
||||
email: "ada@example.com",
|
||||
password: "hunter2",
|
||||
confirmPassword: "hunter2",
|
||||
isSubmitting: true,
|
||||
},
|
||||
};
|
||||
@@ -99,8 +99,12 @@ export default function SignupForm({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Collapsed to zero height rather than unmounted so it can animate open,
|
||||
so `inert` is what keeps the field out of the tab order while
|
||||
aria-hidden keeps it off the accessibility tree. */}
|
||||
<div
|
||||
aria-hidden={!showConfirm}
|
||||
inert={!showConfirm}
|
||||
className="auth-confirm"
|
||||
style={{
|
||||
maxHeight: showConfirm ? 96 : 0,
|
||||
|
||||
@@ -176,6 +176,7 @@ export const ProfilePictureCropper: React.FC<ProfilePictureCropperProps> = ({
|
||||
step={0.1}
|
||||
onChange={setZoom}
|
||||
disabled={processing}
|
||||
thumbLabel={t("config.account.profilePicture.cropper.zoom", "Zoom")}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Magic-link sign-in on the SaaS login screen. Collapsed it is a single link;
|
||||
* expanded it becomes an email field and a send button, so the two states are
|
||||
* really two different components sharing a prop.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import MagicLinkForm from "@app/routes/login/MagicLinkForm";
|
||||
|
||||
const meta: Meta<typeof MagicLinkForm> = {
|
||||
title: "SaaS/Login/MagicLinkForm",
|
||||
component: MagicLinkForm,
|
||||
parameters: { layout: "padded" },
|
||||
args: {
|
||||
showMagicLink: false,
|
||||
magicLinkEmail: "",
|
||||
setMagicLinkEmail: () => {},
|
||||
setShowMagicLink: () => {},
|
||||
onSubmit: () => {},
|
||||
isSubmitting: false,
|
||||
},
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof MagicLinkForm>;
|
||||
|
||||
/** Collapsed — just the link that opens the form. */
|
||||
export const Collapsed: Story = {};
|
||||
|
||||
export const Expanded: Story = { args: { showMagicLink: true } };
|
||||
|
||||
export const WithEmail: Story = {
|
||||
args: { showMagicLink: true, magicLinkEmail: "a.whitfield@example.com" },
|
||||
};
|
||||
|
||||
/** Sending, which disables the field and the button together. */
|
||||
export const Submitting: Story = {
|
||||
args: {
|
||||
showMagicLink: true,
|
||||
magicLinkEmail: "a.whitfield@example.com",
|
||||
isSubmitting: true,
|
||||
},
|
||||
};
|
||||
|
||||
/** Typing for real, so the field and its clear/submit states track input. */
|
||||
export const Interactive: Story = {
|
||||
render: function Interactive() {
|
||||
const [open, setOpen] = useState(true);
|
||||
const [email, setEmail] = useState("");
|
||||
return (
|
||||
<MagicLinkForm
|
||||
showMagicLink={open}
|
||||
magicLinkEmail={email}
|
||||
setMagicLinkEmail={setEmail}
|
||||
setShowMagicLink={setOpen}
|
||||
onSubmit={() => {}}
|
||||
isSubmitting={false}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user