diff --git a/SW.Bitween.Api/Services/RunFlagUpdater.cs b/SW.Bitween.Api/Services/RunFlagUpdater.cs index 5a1094d7..4b377d87 100644 --- a/SW.Bitween.Api/Services/RunFlagUpdater.cs +++ b/SW.Bitween.Api/Services/RunFlagUpdater.cs @@ -21,21 +21,21 @@ public async Task MarkAsRunning(int id) { var sqlUpdate = _dbType.ToLower() switch { - "pgsql" => $@"UPDATE infolink.subscription SET is_running = true - WHERE id = '{id}' and is_running = false + "pgsql" => @"UPDATE infolink.subscription SET is_running = true + WHERE id = {0} and is_running = false RETURNING is_running", - "mssql" => $@"UPDATE Subscriptions SET IsRunning = 1 + "mssql" => @"UPDATE Subscriptions SET IsRunning = 1 OUTPUT INSERTED.IsRunning - WHERE Id = '{id}' and IsRunning = 0", - "mysql" => $@"SELECT IsRunning FROM Subscriptions - WHERE Id = '{id}' and IsRunning = false + WHERE Id = {0} and IsRunning = 0", + "mysql" => @"SELECT IsRunning FROM Subscriptions + WHERE Id = {0} and IsRunning = false FOR UPDATE; UPDATE Subscriptions SET IsRunning = true - WHERE Id = '{id}' and IsRunning = false", + WHERE Id = {0} and IsRunning = false", _ => "" }; - var results = await dbContext.Set().FromSqlRaw(sqlUpdate).ToListAsync(); + var results = await dbContext.Set().FromSqlRaw(sqlUpdate, id).ToListAsync(); var result = results.SingleOrDefault(); // result is null when is running is true @@ -46,17 +46,17 @@ public async Task MarkAsIdle(int id) { var sqlUpdate = _dbType.ToLower() switch { - "pgsql" => $@"UPDATE infolink.subscription SET is_running = false - WHERE id = '{id}'", - "mssql" => $@"UPDATE Subscriptions SET IsRunning = 0 - WHERE Id = '{id}'", - "mysql" => $@"UPDATE Subscriptions SET IsRunning = false - WHERE Id = '{id}'", + "pgsql" => @"UPDATE infolink.subscription SET is_running = false + WHERE id = {0}", + "mssql" => @"UPDATE Subscriptions SET IsRunning = 0 + WHERE Id = {0}", + "mysql" => @"UPDATE Subscriptions SET IsRunning = false + WHERE Id = {0}", _ => "" }; ; - await dbContext.Database.ExecuteSqlRawAsync(sqlUpdate); + await dbContext.Database.ExecuteSqlRawAsync(sqlUpdate, id); } public class RunningResult diff --git a/SW.Bitween.IntegrationTests/Tests/RunFlagUpdaterTests.cs b/SW.Bitween.IntegrationTests/Tests/RunFlagUpdaterTests.cs new file mode 100644 index 00000000..079b85f8 --- /dev/null +++ b/SW.Bitween.IntegrationTests/Tests/RunFlagUpdaterTests.cs @@ -0,0 +1,72 @@ +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using SW.Bitween.Domain; +using SW.Bitween.IntegrationTests.Fixtures; +using SW.Bitween.Model; +using Xunit; + +namespace SW.Bitween.IntegrationTests.Tests; + +// The run flag is written with raw (parameterized) SQL that differs per database +// provider, so it needs a real round trip to prove the statement and its parameter +// binding are correct. There was no coverage here before. +[Collection("Bitween")] +public class RunFlagUpdaterTests +{ + private readonly BitweenFixture _fixture; + + public RunFlagUpdaterTests(BitweenFixture fixture) + { + _fixture = fixture; + } + + [Fact] + public async Task Run_flag_claims_once_then_blocks_until_idle() + { + await using var scope = _fixture.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var runFlag = scope.ServiceProvider.GetRequiredService(); + + var document = new Document(6101, "Run Flag Test Doc"); + db.Set().Add(document); + var subscription = new Subscription("Run Flag Test", document.Id); + subscription.Inactive = false; + db.Set().Add(subscription); + await db.SaveChangesAsync(); + + Assert.True(await runFlag.MarkAsRunning(subscription.Id)); // first claim wins + Assert.False(await runFlag.MarkAsRunning(subscription.Id)); // already running + + await runFlag.MarkAsIdle(subscription.Id); + + Assert.True(await runFlag.MarkAsRunning(subscription.Id)); // claimable again + await runFlag.MarkAsIdle(subscription.Id); + } + + // Guards the parameter binding: a broken placeholder would either match no rows + // or every row, and both would show up here. + [Fact] + public async Task Run_flag_only_affects_the_requested_subscription() + { + await using var scope = _fixture.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var runFlag = scope.ServiceProvider.GetRequiredService(); + + var document = new Document(6102, "Run Flag Isolation Doc"); + db.Set().Add(document); + var a = new Subscription("Run Flag A", document.Id); + a.Inactive = false; + var b = new Subscription("Run Flag B", document.Id); + b.Inactive = false; + db.Set().AddRange(a, b); + await db.SaveChangesAsync(); + + Assert.True(await runFlag.MarkAsRunning(a.Id)); + Assert.True(await runFlag.MarkAsRunning(b.Id)); // b untouched by a's update + + await runFlag.MarkAsIdle(a.Id); + Assert.False(await runFlag.MarkAsRunning(b.Id)); // b still running, a's idle did not clear it + + await runFlag.MarkAsIdle(b.Id); + } +} diff --git a/SW.Bitween.Web/ClientApp/package.json b/SW.Bitween.Web/ClientApp/package.json index b18dda7a..d0802f70 100644 --- a/SW.Bitween.Web/ClientApp/package.json +++ b/SW.Bitween.Web/ClientApp/package.json @@ -13,13 +13,17 @@ }, "dependencies": { "@azure/msal-browser": "^4.0.1", + "@babel/runtime": "^8.0.0", + "@codemirror/language": "^6.12.4", + "@codemirror/state": "^6.7.2", + "@codemirror/view": "^6.43.10", "@fontsource-variable/instrument-sans": "^5.2.8", "@fontsource-variable/jetbrains-mono": "^5.2.8", "@headlessui/react": "^2.2.10", - "@monaco-editor/loader": "^1.7.0", - "@monaco-editor/react": "^4.7.0", + "@lezer/highlight": "^1.2.3", "@tailwindcss/vite": "^4.3.2", "@tanstack/react-query": "^5.101.2", + "@uiw/react-codemirror": "^4.25.11", "immer": "^11.1.8", "lucide-react": "^1.24.0", "react": "^19.2.7", diff --git a/SW.Bitween.Web/ClientApp/src/auth/SessionContext.tsx b/SW.Bitween.Web/ClientApp/src/auth/SessionContext.tsx index 7c520dd7..8f49f237 100644 --- a/SW.Bitween.Web/ClientApp/src/auth/SessionContext.tsx +++ b/SW.Bitween.Web/ClientApp/src/auth/SessionContext.tsx @@ -9,6 +9,7 @@ import { } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { api, type PermissionKey, type Session } from "../api"; +import { useIdleLogout } from "./useIdleLogout"; interface SessionContextValue { session: Session | null; @@ -72,6 +73,10 @@ export function SessionProvider({ children }: { children: ReactNode }) { setSession(null); }, [queryClient]); + // Finding #4 in the 9USRCraft pen test: an authenticated session stayed usable + // indefinitely. Signs out after 30 minutes with no activity in any tab. + useIdleLogout(!!session, signOut); + const value = useMemo( () => ({ session, diff --git a/SW.Bitween.Web/ClientApp/src/auth/useIdleLogout.ts b/SW.Bitween.Web/ClientApp/src/auth/useIdleLogout.ts new file mode 100644 index 00000000..ba1130e3 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/auth/useIdleLogout.ts @@ -0,0 +1,91 @@ +import { useEffect, useRef } from "react"; + +const IDLE_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes +const CHECK_INTERVAL_MS = 30 * 1000; // re-check every 30s +const ACTIVITY_EVENTS = ["mousemove", "mousedown", "keydown", "scroll", "touchstart"]; +/** Shared so activity in any tab keeps every tab alive (see the interval below). */ +const LAST_ACTIVITY_KEY = "last_activity"; + +const readSharedActivity = (): number => { + try { + return Number(localStorage.getItem(LAST_ACTIVITY_KEY)) || 0; + } catch { + return 0; // storage unavailable: fall back to this tab's own timer + } +}; + +const writeSharedActivity = (ts: number) => { + try { + localStorage.setItem(LAST_ACTIVITY_KEY, String(ts)); + } catch { + // ignore: the in-tab ref still tracks activity + } +}; + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +/** + * Signs the user out after a period with no activity in any tab. Runs the normal + * sign-out path, so an idle session ends exactly like clicking Sign out. + */ +export function useIdleLogout( + enabled: boolean, + signOut: () => Promise, + timeoutMs: number = IDLE_TIMEOUT_MS, +) { + const lastActivity = useRef(Date.now()); + // Held in a ref so a new signOut identity doesn't restart the idle timer. + const signOutRef = useRef(signOut); + useEffect(() => { + signOutRef.current = signOut; + }, [signOut]); + + useEffect(() => { + if (!enabled) return; + + // Start counting from the moment we become enabled. The ref is created when the + // provider first mounts, which is while the login page is showing and no activity + // listeners are attached, so without this a login page left open longer than + // timeoutMs would sign the user out immediately after signing in. + const enabledAt = Date.now(); + lastActivity.current = enabledAt; + writeSharedActivity(enabledAt); + + const markActivity = () => { + const ts = Date.now(); + lastActivity.current = ts; + writeSharedActivity(ts); + }; + ACTIVITY_EVENTS.forEach((e) => window.addEventListener(e, markActivity, { passive: true })); + + const interval = window.setInterval(async () => { + // Activity events only fire in the focused tab, so take the most recent activity + // across tabs. Otherwise a background tab would sign out a user who is actively + // working in another one. + const last = Math.max(lastActivity.current, readSharedActivity()); + if (Date.now() - last < timeoutMs) return; + + window.clearInterval(interval); + ACTIVITY_EVENTS.forEach((e) => window.removeEventListener(e, markActivity)); + try { + await signOutRef.current(); + } catch { + // Only the server call invalidates the refresh-token cookie (it is HttpOnly, + // so JS cannot clear it). Retry once before giving up. + await sleep(2000); + try { + await signOutRef.current(); + } catch { + // api.logout() clears the stored Jwt in a finally, so it is already gone by + // now; reloading is what drops the in-memory session and shows the login page. + window.location.reload(); + } + } + }, CHECK_INTERVAL_MS); + + return () => { + window.clearInterval(interval); + ACTIVITY_EVENTS.forEach((e) => window.removeEventListener(e, markActivity)); + }; + }, [enabled, timeoutMs]); +} diff --git a/SW.Bitween.Web/ClientApp/src/components/mapper/ManualEditor.tsx b/SW.Bitween.Web/ClientApp/src/components/mapper/ManualEditor.tsx index a21078cd..61092bff 100644 --- a/SW.Bitween.Web/ClientApp/src/components/mapper/ManualEditor.tsx +++ b/SW.Bitween.Web/ClientApp/src/components/mapper/ManualEditor.tsx @@ -1,5 +1,7 @@ -import React, { lazy, Suspense, useEffect, useRef } from "react"; -import loader from "@monaco-editor/loader"; +import React, { useEffect } from "react"; +import CodeMirror from "@uiw/react-codemirror"; +import { EditorView } from "@codemirror/view"; +import { scribanLanguage } from "./scribanLanguage"; import { useMappingEditorDispatch, useMappingEditorState, @@ -10,21 +12,19 @@ import { } from "../../lib/mapping/MappingEditorContext"; import { generateScriban, parseScriban } from "../../lib/mapping/scribanGenerator"; -// Pinned to an exact version so the CSP entries in Startup.cs (which allow only -// this path, not all of cdn.jsdelivr.net) stay valid — bump both together. -loader.config({ paths: { vs: "https://cdn.jsdelivr.net/npm/monaco-editor@0.55.1/min/vs" } }); - -// Monaco is heavy — lazy-load it so it stays out of the main bundle. -const MonacoEditor = lazy(() => import("@monaco-editor/react")); - const SCRIBAN_HINT = `{{- # Scriban template — edit freely -}} {{- # Use {{ variable.path }} for values, for/end for loops, if/end for filters -}} `; +const editorTheme = EditorView.theme({ + '&': { height: '100%', backgroundColor: '#ffffff' }, + '.cm-scroller': { fontFamily: 'var(--font-mono)', overflow: 'auto' }, + '&.cm-focused': { outline: 'none' }, +}); + const ManualEditor: React.FC = () => { const dispatch = useMappingEditorDispatch(); const { fieldMappings, arrayMappings, manualTemplate, isManualDirty } = useMappingEditorState(); - const editorRef = useRef(null); const [parseWarnings, setParseWarnings] = React.useState([]); const [parseSuccess, setParseSuccess] = React.useState(false); @@ -39,14 +39,15 @@ const ManualEditor: React.FC = () => { } }, []); // Only on mount — ongoing sync is handled by handleModeChange - const handleEditorChange = (value: string | undefined) => { - dispatch(setManualTemplate(value ?? '')); + const handleEditorChange = (value: string) => { + dispatch(setManualTemplate(value)); }; const handleRegenerateFromVisual = () => { const generated = generateScriban(fieldMappings, arrayMappings, undefined, undefined); + // syncManualTemplate updates manualTemplate, which flows into the editor's + // controlled `value` below — no imperative setValue needed. dispatch(syncManualTemplate(generated)); - editorRef.current?.setValue(generated); setParseWarnings([]); setParseSuccess(false); }; @@ -77,11 +78,15 @@ const ManualEditor: React.FC = () => { } }; - const templateToShow = - manualTemplate || - (fieldMappings.length > 0 || arrayMappings.length > 0 - ? generateScriban(fieldMappings, arrayMappings, undefined, undefined) - : SCRIBAN_HINT); + // Once the user has edited the template (isManualDirty), show it verbatim so an + // intentional empty template stays empty. Only before any edit do we fall back to + // the generated template / hint. + const templateToShow = isManualDirty + ? manualTemplate + : manualTemplate || + (fieldMappings.length > 0 || arrayMappings.length > 0 + ? generateScriban(fieldMappings, arrayMappings, undefined, undefined) + : SCRIBAN_HINT); return (
@@ -130,31 +135,21 @@ const ManualEditor: React.FC = () => {
)} - {/* Monaco Editor */} + {/* CodeMirror editor */}
- Loading editor…
}> - { - editorRef.current = ed; - }} - options={{ - minimap: { enabled: true }, - fontSize: 13, - lineNumbers: 'on', - wordWrap: 'on', - scrollBeyondLastLine: false, - fontFamily: '"Fira Code", "JetBrains Mono", monospace', - automaticLayout: true, - formatOnPaste: true, - tabSize: 2, - suggest: { snippetsPreventQuickSuggestions: false }, - }} - /> - + {/* Scriban cheat sheet */} @@ -164,8 +159,8 @@ const ManualEditor: React.FC = () => { {'{{ var.path }}'} value - {'{{- for item in arr -}}'} …{' '} - {'{{- end -}}'} loop + {'{{- for item in arr -}}'} …{' '} + {'{{- end -}}'} loop {'{{- if expr -}}'} …{' '} diff --git a/SW.Bitween.Web/ClientApp/src/components/mapper/__tests__/scribanLanguage.test.ts b/SW.Bitween.Web/ClientApp/src/components/mapper/__tests__/scribanLanguage.test.ts new file mode 100644 index 00000000..8c7a51d1 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/components/mapper/__tests__/scribanLanguage.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect } from 'vitest'; +import { StringStream } from '@codemirror/language'; +import { scribanStreamParser } from '../scribanLanguage'; + +interface Tok { + text: string; + style: string | null; +} + +// Drive the StreamLanguage tokenizer the same way CodeMirror does: one StringStream +// per line, sharing a single state object so `inTag` carries across lines. +function tokenize(src: string): Tok[] { + const state = scribanStreamParser.startState!(2); + const out: Tok[] = []; + for (const line of src.split('\n')) { + const stream = new StringStream(line, 2, 2); + while (!stream.eol()) { + stream.start = stream.pos; + const style = scribanStreamParser.token!(stream, state); + if (stream.pos === stream.start) stream.pos++; // guard: never stall + out.push({ text: stream.string.slice(stream.start, stream.pos), style: style ?? null }); + } + } + return out; +} + +const styleOf = (toks: Tok[], text: string) => toks.find((t) => t.text === text)?.style; + +describe('scriban tokenizer', () => { + it('marks braces and a variable path inside a tag', () => { + const toks = tokenize('{{ user.name }}'); + expect(styleOf(toks, '{{')).toBe('brace'); + expect(styleOf(toks, 'user.name')).toBe('variable'); + expect(styleOf(toks, '}}')).toBe('brace'); + }); + + it('handles trim delimiters and control keywords', () => { + const toks = tokenize('{{- for x in items -}}'); + expect(styleOf(toks, '{{-')).toBe('brace'); + expect(styleOf(toks, 'for')).toBe('keyword'); + expect(styleOf(toks, 'in')).toBe('keyword'); + expect(styleOf(toks, 'items')).toBe('variable'); + expect(styleOf(toks, '-}}')).toBe('brace'); + }); + + it('treats # to the end of the tag as a comment', () => { + const toks = tokenize('{{ # a comment }}'); + const comment = toks.find((t) => t.style === 'comment'); + expect(comment).toBeDefined(); + expect(comment!.text).toContain('# a comment'); + // the closing braces are still recognised after the comment + expect(styleOf(toks, '}}')).toBe('brace'); + }); + + it('recognises quoted strings and operators', () => { + const toks = tokenize('{{ x = "hello world" }}'); + expect(styleOf(toks, '"hello world"')).toBe('string'); + expect(styleOf(toks, '=')).toBe('operator'); + }); + + it('keeps tag state across multiple lines', () => { + const toks = tokenize('{{ for x\nin y }}'); + expect(styleOf(toks, 'for')).toBe('keyword'); // line 1, still open + expect(styleOf(toks, 'in')).toBe('keyword'); // line 2, tag continued + expect(styleOf(toks, '}}')).toBe('brace'); // line 2, tag closed + }); + + it('does not style plain text outside tags', () => { + const toks = tokenize('plain text {{ v }}'); + expect(styleOf(toks, 'plain text ')).toBeNull(); + expect(styleOf(toks, 'v')).toBe('variable'); + }); +}); diff --git a/SW.Bitween.Web/ClientApp/src/components/mapper/scribanLanguage.ts b/SW.Bitween.Web/ClientApp/src/components/mapper/scribanLanguage.ts new file mode 100644 index 00000000..e0309a02 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/components/mapper/scribanLanguage.ts @@ -0,0 +1,71 @@ +// Minimal Scriban/mustache highlighting for CodeMirror 6. +// Highlights `{{ ... }}` regions (delimiters, keywords, comments, values) without +// pulling in a full grammar. Colors mirror the cheat-sheet under the editor: +// brand crimson for values/braces, warn for control keywords, ink for comments. +import { StreamLanguage, HighlightStyle, syntaxHighlighting } from '@codemirror/language'; +import type { StreamParser } from '@codemirror/language'; +import { Prec } from '@codemirror/state'; +import { tags as t } from '@lezer/highlight'; + +const KEYWORDS = /^(for|end|if|else|in|while|func|ret|break|continue|capture|case|when|do|with|wrap|tablerow)\b/; + +// Exported for unit testing (see __tests__/scribanLanguage.test.ts). +export const scribanStreamParser: StreamParser<{ inTag: boolean }> = { + startState: () => ({ inTag: false }), + token(stream, state) { + if (!state.inTag) { + if (stream.match(/^\{\{-?/)) { + state.inTag = true; + return 'brace'; + } + // Plain text between tags: advance to the next `{{` (always consuming ≥1 char). + while (!stream.eol() && !stream.match(/^\{\{/, false)) stream.next(); + return null; + } + // Inside a `{{ ... }}` tag. + if (stream.match(/^-?\}\}/)) { + state.inTag = false; + return 'brace'; + } + if (stream.peek() === '#') { + while (!stream.eol() && !stream.match(/^-?\}\}/, false)) stream.next(); + return 'comment'; + } + if (stream.match(KEYWORDS)) return 'keyword'; + if (stream.match(/^"(?:[^"\\]|\\.)*"?/)) return 'string'; + if (stream.match(/^'(?:[^'\\]|\\.)*'?/)) return 'string'; + if (stream.match(/^\d+(?:\.\d+)?/)) return 'number'; + if (stream.match(/^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*/)) return 'variable'; + if (stream.match(/^[-+*/%=!<>|&.?:,()[\]]+/)) return 'operator'; + stream.next(); + return null; + }, + tokenTable: { + brace: t.brace, + keyword: t.keyword, + comment: t.lineComment, + string: t.string, + number: t.number, + variable: t.variableName, + operator: t.operator, + }, +}; + +const scribanParser = StreamLanguage.define(scribanStreamParser); + +// Colors come from the design tokens rather than literals so a re-branded tenant +// (Theme.PrimaryColor rewrites every --color-crimson-*) keeps the editor and the +// cheat-sheet under it in step — they are a matched pair, the sheet is what tells +// the user what each color means. +const scribanHighlight = HighlightStyle.define([ + { tag: t.brace, color: 'var(--color-crimson-600)', fontWeight: 'bold' }, // {{ }} + { tag: t.variableName, color: 'var(--color-crimson-600)' }, // var.path + { tag: t.keyword, color: 'var(--color-warn-700)', fontWeight: 'bold' }, // for / if / end + { tag: t.lineComment, color: 'var(--color-ink-400)', fontStyle: 'italic' }, + { tag: t.string, color: 'var(--color-ok-600)' }, + { tag: t.number, color: 'var(--color-ok-800)' }, + { tag: t.operator, color: 'var(--color-ink-500)' }, +]); + +// Prec.highest so our colors win over basic-setup's default highlight style. +export const scribanLanguage = [scribanParser, Prec.highest(syntaxHighlighting(scribanHighlight))]; diff --git a/SW.Bitween.Web/ClientApp/src/lib/__tests__/passwordPolicy.test.ts b/SW.Bitween.Web/ClientApp/src/lib/__tests__/passwordPolicy.test.ts new file mode 100644 index 00000000..9a7c59d4 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/lib/__tests__/passwordPolicy.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect } from 'vitest'; +import { validatePassword } from '../passwordPolicy'; + +const TOO_SHORT = 'Password must be at least 8 characters.'; +const NO_UPPER = 'Password must contain an uppercase letter.'; +const NO_LOWER = 'Password must contain a lowercase letter.'; +const NO_NUMBER = 'Password must contain a number.'; +const NO_SPECIAL = 'Password must contain a special character.'; + +describe('validatePassword', () => { + it('reports every rule for undefined', () => { + expect(validatePassword(undefined)).toEqual([ + TOO_SHORT, NO_UPPER, NO_LOWER, NO_NUMBER, NO_SPECIAL, + ]); + }); + + it('reports every rule for an empty string, in order', () => { + expect(validatePassword('')).toEqual([ + TOO_SHORT, NO_UPPER, NO_LOWER, NO_NUMBER, NO_SPECIAL, + ]); + }); + + it('rejects 7 characters and accepts 8 (length boundary)', () => { + expect(validatePassword('Abc1!de')).toEqual([TOO_SHORT]); // 7 chars, all classes present + expect(validatePassword('Abc1!def')).toEqual([]); // 8 chars + }); + + it('flags a missing uppercase letter', () => { + expect(validatePassword('abc1!def')).toEqual([NO_UPPER]); + }); + + it('flags a missing lowercase letter', () => { + expect(validatePassword('ABC1!DEF')).toEqual([NO_LOWER]); + }); + + it('flags a missing number', () => { + expect(validatePassword('Abcd!efg')).toEqual([NO_NUMBER]); + }); + + it('flags a missing special character', () => { + expect(validatePassword('Abcd1efg')).toEqual([NO_SPECIAL]); + }); + + it('accepts a valid password', () => { + expect(validatePassword('Str0ng!Pass')).toEqual([]); + }); +}); diff --git a/SW.Bitween.Web/ClientApp/src/lib/passwordPolicy.ts b/SW.Bitween.Web/ClientApp/src/lib/passwordPolicy.ts new file mode 100644 index 00000000..71cd05e6 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/lib/passwordPolicy.ts @@ -0,0 +1,17 @@ +// Client-side mirror of the server password policy. The server is the enforcement +// point; this just gives the user faster feedback with the same messages. +// Keep in sync with PasswordValidationExtensions on the backend. +export const validatePassword = (password: string | undefined): string[] => { + const p = password ?? ""; + const errors: string[] = []; + if (p.length < 8) errors.push("Password must be at least 8 characters."); + if (!/[A-Z]/.test(p)) errors.push("Password must contain an uppercase letter."); + if (!/[a-z]/.test(p)) errors.push("Password must contain a lowercase letter."); + if (!/[0-9]/.test(p)) errors.push("Password must contain a number."); + if (!/[^A-Za-z0-9]/.test(p)) errors.push("Password must contain a special character."); + return errors; +}; + +/** The rule, phrased for a form hint. */ +export const PASSWORD_HINT = + "At least 8 characters, with an uppercase letter, a lowercase letter, a number and a special character."; diff --git a/SW.Bitween.Web/ClientApp/src/pages/ProfilePage.tsx b/SW.Bitween.Web/ClientApp/src/pages/ProfilePage.tsx index beab9fbb..879cb650 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/ProfilePage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/ProfilePage.tsx @@ -9,6 +9,7 @@ import { PageHeader } from "../components/layout/PageHeader"; import { Avatar } from "../components/ui/Avatar"; import { Badge, Button, FormError } from "../components/ui/basics"; import { Field, PasswordInput, TextInput } from "../components/ui/forms"; +import { PASSWORD_HINT, validatePassword } from "../lib/passwordPolicy"; function Card({ title, children }: { title: string; children: ReactNode }) { return ( @@ -78,6 +79,12 @@ export function ProfilePage() { setPasswordError("The new passwords don't match."); return; } + // The server rejects a weak password too; checking here saves the round trip. + const problems = validatePassword(newPassword); + if (problems.length > 0) { + setPasswordError(problems.join(" ")); + return; + } password.mutate(); }; @@ -151,7 +158,7 @@ export function ProfilePage() { onChange={(e) => setCurrentPassword(e.target.value)} /> - + void }) { @@ -14,6 +15,7 @@ export function AddMemberDialog({ onClose }: { onClose: () => void }) { const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [roleIds, setRoleIds] = useState([]); + const [passwordError, setPasswordError] = useState(""); const create = useMutation({ mutationFn: () => api.createUser({ displayName, email, password, roleIds }), @@ -29,6 +31,13 @@ export function AddMemberDialog({ onClose }: { onClose: () => void }) { const submit = (e: FormEvent) => { e.preventDefault(); + // The server rejects a weak password too; checking here saves the round trip. + const problems = validatePassword(password); + if (problems.length > 0) { + setPasswordError(problems.join(" ")); + return; + } + setPasswordError(""); create.mutate(); }; @@ -60,7 +69,7 @@ export function AddMemberDialog({ onClose }: { onClose: () => void }) { void }) { - {create.error instanceof ApiRequestError ? create.error.message : create.error?.message} + {passwordError || + (create.error instanceof ApiRequestError ? create.error.message : create.error?.message)}
diff --git a/SW.Bitween.Web/ClientApp/yarn.lock b/SW.Bitween.Web/ClientApp/yarn.lock index d2f99d00..f161d956 100644 --- a/SW.Bitween.Web/ClientApp/yarn.lock +++ b/SW.Bitween.Web/ClientApp/yarn.lock @@ -14,6 +14,93 @@ resolved "https://registry.yarnpkg.com/@azure/msal-common/-/msal-common-15.17.0.tgz#ae3c03378c852642b1c9a303380e945c2b897f02" integrity sha512-VQ5/gTLFADkwue+FohVuCqlzFPUq4xSrX8jeZe+iwZuY6moliNC8xt86qPVNYdtbQfELDf2Nu6LI+demFPHGgw== +"@babel/runtime@^7.18.6": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.29.7.tgz#12022450c45a4da6d8d8287b18a4ff2ddb23f768" + integrity sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw== + +"@babel/runtime@^8.0.0": + version "8.0.0" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-8.0.0.tgz#d7bd513e6843662346552c2798ab895716cf97f2" + integrity sha512-sL6cvO2IfkSu/iU+zs2S/w01B7A8V7suXSIKEN4hPFFdZoiPGxrj5pAG0lCaqLWiEIrjKzdznIWuaLcxPR53qw== + +"@codemirror/autocomplete@^6.0.0": + version "6.20.3" + resolved "https://registry.yarnpkg.com/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz#696b740312c6a962e14567b49a3661b5924bc5ae" + integrity sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g== + dependencies: + "@codemirror/language" "^6.0.0" + "@codemirror/state" "^6.0.0" + "@codemirror/view" "^6.17.0" + "@lezer/common" "^1.0.0" + +"@codemirror/commands@^6.0.0", "@codemirror/commands@^6.1.0": + version "6.11.0" + resolved "https://registry.yarnpkg.com/@codemirror/commands/-/commands-6.11.0.tgz#2194d6fcad9ed787dcc42667db0e0543fab2e0ef" + integrity sha512-/K4Rl5BN0OtTiPWmJCdqODu38XnDMsDxKY5rgrPnCkutPTJf2wVbkoixLfealF5Kwse/s8P8M5jAiURiwSwnFA== + dependencies: + "@codemirror/language" "^6.0.0" + "@codemirror/state" "^6.7.0" + "@codemirror/view" "^6.27.0" + "@lezer/common" "^1.1.0" + +"@codemirror/language@^6.0.0", "@codemirror/language@^6.12.4": + version "6.12.4" + resolved "https://registry.yarnpkg.com/@codemirror/language/-/language-6.12.4.tgz#01e70fd5aa3a8a067ff1dfec75d5b6394cdfa058" + integrity sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A== + dependencies: + "@codemirror/state" "^6.0.0" + "@codemirror/view" "^6.23.0" + "@lezer/common" "^1.5.0" + "@lezer/highlight" "^1.0.0" + "@lezer/lr" "^1.0.0" + style-mod "^4.0.0" + +"@codemirror/lint@^6.0.0": + version "6.9.7" + resolved "https://registry.yarnpkg.com/@codemirror/lint/-/lint-6.9.7.tgz#841fc733674389d91fe49a1c34027ad3babdf105" + integrity sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg== + dependencies: + "@codemirror/state" "^6.0.0" + "@codemirror/view" "^6.42.0" + crelt "^1.0.5" + +"@codemirror/search@^6.0.0": + version "6.7.2" + resolved "https://registry.yarnpkg.com/@codemirror/search/-/search-6.7.2.tgz#93a49eb21c026de473c4fc3a047b8fd67784b0f0" + integrity sha512-gUYkYhT2+n/+VGZ+8EzE5WFkYZUZYm1VOKDudIsNqh42uRVQJ0a6Yss9sdKT3MeOYfuL1N6AZA57oza0Oyr0LA== + dependencies: + "@codemirror/state" "^6.0.0" + "@codemirror/view" "^6.37.0" + crelt "^1.0.5" + +"@codemirror/state@^6.0.0", "@codemirror/state@^6.1.1", "@codemirror/state@^6.7.0", "@codemirror/state@^6.7.2": + version "6.7.2" + resolved "https://registry.yarnpkg.com/@codemirror/state/-/state-6.7.2.tgz#e85bbdb16000fcd1d9926f4165a3ee51bb890cdc" + integrity sha512-U3RiPX62Wl/Gx4ftQ7UxLlloSfsFTQqKa+7vFBYteZGCzkp6oBqcsD7iSwniWRGobCAmDcwZSY+Six3+3ztdfg== + dependencies: + "@marijn/find-cluster-break" "^1.0.0" + +"@codemirror/theme-one-dark@^6.0.0": + version "6.1.3" + resolved "https://registry.yarnpkg.com/@codemirror/theme-one-dark/-/theme-one-dark-6.1.3.tgz#1dbb73f6e73c53c12ad2aed9f48c263c4e63ea37" + integrity sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA== + dependencies: + "@codemirror/language" "^6.0.0" + "@codemirror/state" "^6.0.0" + "@codemirror/view" "^6.0.0" + "@lezer/highlight" "^1.0.0" + +"@codemirror/view@^6.0.0", "@codemirror/view@^6.17.0", "@codemirror/view@^6.23.0", "@codemirror/view@^6.27.0", "@codemirror/view@^6.37.0", "@codemirror/view@^6.42.0", "@codemirror/view@^6.43.10": + version "6.43.10" + resolved "https://registry.yarnpkg.com/@codemirror/view/-/view-6.43.10.tgz#abb09d825c3b33b51e3632bd5ef97e434a0e1da8" + integrity sha512-vVWLvd4fKWYN/pMcwUrg6dWeublxmSz+V+52ZjW3h64IVN9cRUYLk5Km4JDT9vifxAFfDtNOIFxKYENthcolJQ== + dependencies: + "@codemirror/state" "^6.7.0" + crelt "^1.0.6" + style-mod "^4.1.0" + w3c-keyname "^2.2.4" + "@emnapi/core@1.11.1": version "1.11.1" resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.11.1.tgz#b9e1064f3a6b1631e241e638eb48d736bfd372a6" @@ -163,19 +250,29 @@ "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" -"@monaco-editor/loader@^1.5.0", "@monaco-editor/loader@^1.7.0": - version "1.7.0" - resolved "https://registry.yarnpkg.com/@monaco-editor/loader/-/loader-1.7.0.tgz#967aaa4601b19e913627688dfe8159d57549e793" - integrity sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA== +"@lezer/common@^1.0.0", "@lezer/common@^1.1.0", "@lezer/common@^1.3.0", "@lezer/common@^1.5.0": + version "1.5.2" + resolved "https://registry.yarnpkg.com/@lezer/common/-/common-1.5.2.tgz#d6840db13779e3f1b42e70c9a97c4086d12fae22" + integrity sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ== + +"@lezer/highlight@^1.0.0", "@lezer/highlight@^1.2.3": + version "1.2.3" + resolved "https://registry.yarnpkg.com/@lezer/highlight/-/highlight-1.2.3.tgz#a20f324b71148a2ea9ba6ff42e58bbfaec702857" + integrity sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g== dependencies: - state-local "^1.0.6" + "@lezer/common" "^1.3.0" -"@monaco-editor/react@^4.7.0": - version "4.7.0" - resolved "https://registry.yarnpkg.com/@monaco-editor/react/-/react-4.7.0.tgz#35a1ec01bfe729f38bfc025df7b7bac145602a60" - integrity sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA== +"@lezer/lr@^1.0.0": + version "1.4.10" + resolved "https://registry.yarnpkg.com/@lezer/lr/-/lr-1.4.10.tgz#b3acc36e5ad049b74ddb7719594e7e74d9161ff5" + integrity sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A== dependencies: - "@monaco-editor/loader" "^1.5.0" + "@lezer/common" "^1.0.0" + +"@marijn/find-cluster-break@^1.0.0": + version "1.0.4" + resolved "https://registry.yarnpkg.com/@marijn/find-cluster-break/-/find-cluster-break-1.0.4.tgz#42c2aea61cda307cdb1347444792452d7b5dbfb4" + integrity sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ== "@napi-rs/wasm-runtime@^1.1.4", "@napi-rs/wasm-runtime@^1.1.6": version "1.1.6" @@ -584,6 +681,31 @@ dependencies: csstype "^3.2.2" +"@uiw/codemirror-extensions-basic-setup@4.25.11": + version "4.25.11" + resolved "https://registry.yarnpkg.com/@uiw/codemirror-extensions-basic-setup/-/codemirror-extensions-basic-setup-4.25.11.tgz#a45e0604eb6a29fffa152b684d0c7702c6e69bdf" + integrity sha512-otyFa+n9IOYtEjaKOxPedHkj15fTPUF21wdR9pv0GpZPfuGl27cvmcv6+tognbRu9VvEcsHKE+ESoszeo3KfTw== + dependencies: + "@codemirror/autocomplete" "^6.0.0" + "@codemirror/commands" "^6.0.0" + "@codemirror/language" "^6.0.0" + "@codemirror/lint" "^6.0.0" + "@codemirror/search" "^6.0.0" + "@codemirror/state" "^6.0.0" + "@codemirror/view" "^6.0.0" + +"@uiw/react-codemirror@^4.25.11": + version "4.25.11" + resolved "https://registry.yarnpkg.com/@uiw/react-codemirror/-/react-codemirror-4.25.11.tgz#01f154accdfa3df674dc2e0041067f514df48fc2" + integrity sha512-DYVFAKLX+F/4JS9N/7xexh+TICrlncwkX9HKKInrP1bwO0tSfc3k0GB6oawTYhelVKh20cX3TuRx+NJSkVXuMw== + dependencies: + "@babel/runtime" "^7.18.6" + "@codemirror/commands" "^6.1.0" + "@codemirror/state" "^6.1.1" + "@codemirror/theme-one-dark" "^6.0.0" + "@uiw/codemirror-extensions-basic-setup" "4.25.11" + codemirror "^6.0.0" + "@vitejs/plugin-react@^6.0.3": version "6.0.3" resolved "https://registry.yarnpkg.com/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz#55f1d7f558534d10aef03c007dc208b7c3771ce4" @@ -673,6 +795,19 @@ clsx@^2.0.0: resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.1.1.tgz#eed397c9fd8bd882bfb18deab7102049a2f32999" integrity sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA== +codemirror@^6.0.0: + version "6.0.2" + resolved "https://registry.yarnpkg.com/codemirror/-/codemirror-6.0.2.tgz#4d3fea1ad60b6753f97ca835f2f48c6936a8946e" + integrity sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw== + dependencies: + "@codemirror/autocomplete" "^6.0.0" + "@codemirror/commands" "^6.0.0" + "@codemirror/language" "^6.0.0" + "@codemirror/lint" "^6.0.0" + "@codemirror/search" "^6.0.0" + "@codemirror/state" "^6.0.0" + "@codemirror/view" "^6.0.0" + convert-source-map@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-2.0.0.tgz#4b560f649fc4e918dd0ab75cf4961e8bc882d82a" @@ -683,6 +818,11 @@ cookie-es@^3.1.1: resolved "https://registry.yarnpkg.com/cookie-es/-/cookie-es-3.1.1.tgz#c4a8a16cf88cb5a185b23f4a61d6e9a85eb53287" integrity sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg== +crelt@^1.0.5, crelt@^1.0.6: + version "1.0.7" + resolved "https://registry.yarnpkg.com/crelt/-/crelt-1.0.7.tgz#3b441b2ddfa73161d6a2770aa4cd677f895eaf28" + integrity sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA== + csstype@^3.2.2: version "3.2.3" resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.2.3.tgz#ec48c0f3e993e50648c86da559e2610995cf989a" @@ -997,16 +1137,16 @@ stackback@0.0.2: resolved "https://registry.yarnpkg.com/stackback/-/stackback-0.0.2.tgz#1ac8a0d9483848d1695e418b6d031a3c3ce68e3b" integrity sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw== -state-local@^1.0.6: - version "1.0.7" - resolved "https://registry.yarnpkg.com/state-local/-/state-local-1.0.7.tgz#da50211d07f05748d53009bee46307a37db386d5" - integrity sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w== - std-env@^4.0.0-rc.1: version "4.2.0" resolved "https://registry.yarnpkg.com/std-env/-/std-env-4.2.0.tgz#8ebe0ec60485668ab47227b312f4254cdf80c9d3" integrity sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw== +style-mod@^4.0.0, style-mod@^4.1.0: + version "4.1.3" + resolved "https://registry.yarnpkg.com/style-mod/-/style-mod-4.1.3.tgz#6e9012255bb799bdac37e288f7671b5d71bf9f73" + integrity sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ== + tabbable@^6.0.0: version "6.5.0" resolved "https://registry.yarnpkg.com/tabbable/-/tabbable-6.5.0.tgz#a65101385a4fd6cbd580b7546da0170f307b535d" @@ -1104,6 +1244,11 @@ vitest@^4.1.7: vite "^6.0.0 || ^7.0.0 || ^8.0.0" why-is-node-running "^2.3.0" +w3c-keyname@^2.2.4: + version "2.2.8" + resolved "https://registry.yarnpkg.com/w3c-keyname/-/w3c-keyname-2.2.8.tgz#7b17c8c6883d4e8b86ac8aba79d39e880f8869c5" + integrity sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ== + why-is-node-running@^2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/why-is-node-running/-/why-is-node-running-2.3.0.tgz#a3f69a97107f494b3cdc3bdddd883a7d65cebf04" diff --git a/SW.Bitween.Web/Startup.cs b/SW.Bitween.Web/Startup.cs index ef891316..df5ecf46 100644 --- a/SW.Bitween.Web/Startup.cs +++ b/SW.Bitween.Web/Startup.cs @@ -416,13 +416,12 @@ public void ConfigureServices(IServiceCollection services) /// Mirrors the policy the legacy UI enforces at nginx, minus its nginx-only bits. private const string ContentSecurityPolicy = "default-src 'self'; " + - // The Scriban mapping editor lazy-loads Monaco, which fetches its script, - // stylesheet and language workers from jsdelivr. Pinned to the exact - // version path ManualEditor.tsx configures the loader with — not the - // whole jsdelivr origin — so this must move in lockstep with that pin. - "script-src 'self' https://cdn.jsdelivr.net/npm/monaco-editor@0.55.1/; " + - "worker-src 'self' blob: https://cdn.jsdelivr.net/npm/monaco-editor@0.55.1/; " + - "style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net/npm/monaco-editor@0.55.1/; " + + // No CDN entry, deliberately. The Scriban mapping editor used to lazy-load + // Monaco from jsdelivr, which meant a third party could serve executable + // code into this app. It runs on CodeMirror now, bundled with everything + // else, so nothing outside this origin is allowed to run. + "script-src 'self'; " + + "style-src 'self' 'unsafe-inline'; " + "img-src 'self' data:; " + "connect-src 'self' https://login.microsoftonline.com; " + "frame-src https://login.microsoftonline.com; " +