From cd0dffa87da71ff0b10ddfab35462f89d4167a99 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Thu, 20 Aug 2026 16:17:59 +0530 Subject: [PATCH] feat: complete SSR CRM and refine auth UI --- bun.lock | 29 +- docs/public-api-0.8.json | 8 +- editors/vscode/src/language-server.cjs | 275 ++++++++----- editors/vscode/test/grammar-apis.test.js | 3 - examples/crm-app/.gitignore | 5 + examples/crm-app/README.md | 13 + examples/crm-app/app/api/contacts.ts | 35 ++ examples/crm-app/app/api/dashboard.ts | 33 ++ examples/crm-app/app/api/deals.ts | 15 + examples/crm-app/app/authz/crm.ts | 19 + .../crm-app/app/db/migrations/001_crm.sql | 65 +++ examples/crm-app/app/db/seed.ts | 23 ++ examples/crm-app/app/lib/auth.ts | 32 ++ examples/crm-app/app/lib/workspace.ts | 29 ++ examples/crm-app/app/middleware/authz.ts | 11 + examples/crm-app/app/middleware/protected.ts | 18 + examples/crm-app/app/pages/admin.wrn | 5 + examples/crm-app/app/pages/contacts.wrn | 6 + examples/crm-app/app/pages/dashboard.wrn | 8 + examples/crm-app/app/pages/deals.wrn | 6 + examples/crm-app/app/pages/forbidden.wrn | 6 + examples/crm-app/app/pages/index.wrn | 31 ++ examples/crm-app/app/pages/login.wrn | 10 + examples/crm-app/app/pages/pricing.wrn | 4 + examples/crm-app/app/pages/signup.wrn | 10 + examples/crm-app/app/routes.gen.ts | 116 ++++++ examples/crm-app/app/styles/global.css | 375 ++++++++++++++++++ .../app/types/wrnexus.generated.api-checks.ts | 7 + .../crm-app/app/types/wrnexus.generated.d.ts | 47 +++ .../app/types/wrnexus.plugins.generated.d.ts | 1 + examples/crm-app/package.json | 30 ++ examples/crm-app/test/crm.test.ts | 82 ++++ examples/crm-app/tsconfig.json | 5 + examples/crm-app/wrnexus.config.ts | 38 ++ packages/auth/components/SignIn.wrn | 26 +- packages/auth/components/SignUp.wrn | 26 +- packages/auth/package.json | 2 +- packages/auth/test/components.test.ts | 11 + packages/cli/package.json | 2 +- packages/cli/src/db.ts | 77 +++- packages/cli/src/update.ts | 1 - packages/dev-server/package.json | 2 +- packages/dev-server/src/runtime.ts | 16 +- .../test/api-call-error-context.test.ts | 98 +++++ packages/ui/components/StrongPassword.wrn | 78 +++- packages/ui/components/TogglePassword.wrn | 20 +- packages/ui/components/button.wrn | 6 + packages/ui/package.json | 2 +- packages/ui/test/ui.test.ts | 12 + 49 files changed, 1640 insertions(+), 139 deletions(-) create mode 100644 examples/crm-app/.gitignore create mode 100644 examples/crm-app/README.md create mode 100644 examples/crm-app/app/api/contacts.ts create mode 100644 examples/crm-app/app/api/dashboard.ts create mode 100644 examples/crm-app/app/api/deals.ts create mode 100644 examples/crm-app/app/authz/crm.ts create mode 100644 examples/crm-app/app/db/migrations/001_crm.sql create mode 100644 examples/crm-app/app/db/seed.ts create mode 100644 examples/crm-app/app/lib/auth.ts create mode 100644 examples/crm-app/app/lib/workspace.ts create mode 100644 examples/crm-app/app/middleware/authz.ts create mode 100644 examples/crm-app/app/middleware/protected.ts create mode 100644 examples/crm-app/app/pages/admin.wrn create mode 100644 examples/crm-app/app/pages/contacts.wrn create mode 100644 examples/crm-app/app/pages/dashboard.wrn create mode 100644 examples/crm-app/app/pages/deals.wrn create mode 100644 examples/crm-app/app/pages/forbidden.wrn create mode 100644 examples/crm-app/app/pages/index.wrn create mode 100644 examples/crm-app/app/pages/login.wrn create mode 100644 examples/crm-app/app/pages/pricing.wrn create mode 100644 examples/crm-app/app/pages/signup.wrn create mode 100644 examples/crm-app/app/routes.gen.ts create mode 100644 examples/crm-app/app/styles/global.css create mode 100644 examples/crm-app/app/types/wrnexus.generated.api-checks.ts create mode 100644 examples/crm-app/app/types/wrnexus.generated.d.ts create mode 100644 examples/crm-app/app/types/wrnexus.plugins.generated.d.ts create mode 100644 examples/crm-app/package.json create mode 100644 examples/crm-app/test/crm.test.ts create mode 100644 examples/crm-app/tsconfig.json create mode 100644 examples/crm-app/wrnexus.config.ts create mode 100644 packages/dev-server/test/api-call-error-context.test.ts diff --git a/bun.lock b/bun.lock index 93cd5831..966c177e 100644 --- a/bun.lock +++ b/bun.lock @@ -93,6 +93,25 @@ "typescript": "^6.0.3", }, }, + "examples/crm-app": { + "name": "wrnexus-crm-example", + "version": "0.8.0", + "dependencies": { + "@wrnexus/auth": "workspace:*", + "@wrnexus/authz": "workspace:*", + "@wrnexus/core": "workspace:*", + "@wrnexus/db": "workspace:*", + "@wrnexus/styles": "workspace:*", + }, + "devDependencies": { + "@iconify-json/lucide": "^1.2.123", + "@iconify/tailwind4": "^1.2.3", + "@tailwindcss/cli": "^4.3.3", + "@types/bun": "^1.3.14", + "tailwindcss": "^4.3.3", + "typescript": "^6.0.3", + }, + }, "examples/i18n-showcase": { "name": "i18n-showcase", "version": "0.8.0", @@ -216,7 +235,7 @@ }, "packages/auth": { "name": "@wrnexus/auth", - "version": "0.8.12", + "version": "0.8.13", "dependencies": { "@wrnexus/authz": "workspace:*", "@wrnexus/captcha": "workspace:*", @@ -272,7 +291,7 @@ }, "packages/cli": { "name": "@wrnexus/cli", - "version": "0.8.46", + "version": "0.8.47", "bin": { "wrnexus": "src/index.ts", }, @@ -337,7 +356,7 @@ }, "packages/dev-server": { "name": "@wrnexus/dev-server", - "version": "0.8.41", + "version": "0.8.42", "dependencies": { "@wrnexus/authz": "workspace:*", "@wrnexus/cache": "workspace:*", @@ -642,7 +661,7 @@ }, "packages/ui": { "name": "@wrnexus/ui", - "version": "0.8.20", + "version": "0.8.21", "dependencies": { "@wrnexus/core": "workspace:*", }, @@ -1406,6 +1425,8 @@ "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], + "wrnexus-crm-example": ["wrnexus-crm-example@workspace:examples/crm-app"], + "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], diff --git a/docs/public-api-0.8.json b/docs/public-api-0.8.json index dd97a11c..c506a875 100644 --- a/docs/public-api-0.8.json +++ b/docs/public-api-0.8.json @@ -1840,7 +1840,7 @@ "Position", "Range", "TextDocument", - "WRN_COMPLETIONS", + "WRN_KEYWORDS", "WorkspaceCompletionItem", "clearWorkspaceIndexCache", "completionItems", @@ -1861,7 +1861,11 @@ "workspaceCompletionItems", "workspaceSymbolLocations" ], - "./server": [] + "./server": [ + "ApiCallCompletionItem", + "apiCallCompletions", + "apiCallHover" + ] }, "@wrnexus/mcp": { ".": [ diff --git a/editors/vscode/src/language-server.cjs b/editors/vscode/src/language-server.cjs index 060e3a2d..426b1823 100644 --- a/editors/vscode/src/language-server.cjs +++ b/editors/vscode/src/language-server.cjs @@ -1,11 +1,12 @@ #!/usr/bin/env node -// WRN editor language server source hash: f75d793688bdb42225d2314660c87852a108b76311974be9307d11e42ecfbb41 +// WRN editor language server source hash: 70d2454817cc3aa546304c880e0a82313bb73b50051556bf7febd12e6a46c78c // WRN editor language server generator hash: f593a44aaf05495b789ce7a3086bee1eebb951b884d41c0e017bbcfe5f547e72 // @bun @bun-cjs (function(exports, require, module, __filename, __dirname) {var __create = Object.create; var __getProtoOf = Object.getPrototypeOf; var __defProp = Object.defineProperty; var __getOwnPropNames = Object.getOwnPropertyNames; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __hasOwnProp = Object.prototype.hasOwnProperty; function __accessProp(key) { return this[key]; @@ -32,7 +33,37 @@ var __toESM = (mod, isNodeMode, target) => { cache.set(mod, to); return to; }; +var __toCommonJS = (from) => { + var entry = (__moduleCache ??= new WeakMap).get(from), desc; + if (entry) + return entry; + entry = __defProp({}, "__esModule", { value: true }); + if (from && typeof from === "object" || typeof from === "function") { + for (var key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(entry, key)) + __defProp(entry, key, { + get: __accessProp.bind(from, key), + enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable + }); + } + __moduleCache.set(from, entry); + return entry; +}; +var __moduleCache; var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports); +var __returnValue = (v) => v; +function __exportSetter(name, newValue) { + this[name] = __returnValue.bind(null, newValue); +} +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { + get: all[name], + enumerable: true, + configurable: true, + set: __exportSetter.bind(all, name) + }); +}; // node_modules/.bun/typescript@6.0.3/node_modules/typescript/lib/typescript.js var require_typescript = __commonJS((exports2, module2) => { @@ -54,10 +85,10 @@ var require_typescript = __commonJS((exports2, module2) => { var ts = {}; ((module3) => { var __defProp2 = Object.defineProperty; - var __getOwnPropDesc = Object.getOwnPropertyDescriptor; + var __getOwnPropDesc2 = Object.getOwnPropertyDescriptor; var __getOwnPropNames2 = Object.getOwnPropertyNames; var __hasOwnProp2 = Object.prototype.hasOwnProperty; - var __export = (target, all) => { + var __export2 = (target, all) => { for (var name in all) __defProp2(target, name, { get: all[name], enumerable: true }); }; @@ -65,13 +96,13 @@ var require_typescript = __commonJS((exports2, module2) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames2(from)) if (!__hasOwnProp2.call(to, key) && key !== except) - __defProp2(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + __defProp2(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc2(from, key)) || desc.enumerable }); } return to; }; - var __toCommonJS = (mod) => (__copyProps, mod); + var __toCommonJS2 = (mod) => (__copyProps, mod); var typescript_exports = {}; - __export(typescript_exports, { + __export2(typescript_exports, { ANONYMOUS: () => ANONYMOUS, AccessFlags: () => AccessFlags, AssertionLevel: () => AssertionLevel, @@ -2321,7 +2352,7 @@ var require_typescript = __commonJS((exports2, module2) => { writeFileEnsuringDirectories: () => writeFileEnsuringDirectories, zipWith: () => zipWith }); - module3.exports = __toCommonJS(typescript_exports); + module3.exports = __toCommonJS2(typescript_exports); var versionMajorMinor = "6.0"; var version = "6.0.3"; var Comparison = /* @__PURE__ */ ((Comparison3) => { @@ -4851,8 +4882,8 @@ ${lanes.join(` return compareValues(left.length, right.length); } var VersionRange = class _VersionRange { - constructor(spec2) { - this._alternatives = spec2 ? Debug.checkDefined(parseRange(spec2), "Invalid range spec.") : emptyArray; + constructor(spec3) { + this._alternatives = spec3 ? Debug.checkDefined(parseRange(spec3), "Invalid range spec.") : emptyArray; } static tryParse(text) { const sets = parseRange(text); @@ -5055,7 +5086,7 @@ ${lanes.join(` } var timestamp = nativePerformanceTime ? () => nativePerformanceTime.now() : Date.now; var ts_performance_exports = {}; - __export(ts_performance_exports, { + __export2(ts_performance_exports, { clearMarks: () => clearMarks, clearMeasures: () => clearMeasures, createTimer: () => createTimer, @@ -21581,19 +21612,19 @@ ${lanes.join(` if (specs === undefined || specs.length === 0) { return; } - return flatMap(specs, (spec2) => spec2 && getSubPatternFromSpec(spec2, basePath, usage, wildcardMatchers[usage])); + return flatMap(specs, (spec3) => spec3 && getSubPatternFromSpec(spec3, basePath, usage, wildcardMatchers[usage])); } function isImplicitGlob(lastPathComponent) { return !/[.*?]/.test(lastPathComponent); } - function getPatternFromSpec(spec2, basePath, usage) { - const pattern = spec2 && getSubPatternFromSpec(spec2, basePath, usage, wildcardMatchers[usage]); + function getPatternFromSpec(spec3, basePath, usage) { + const pattern = spec3 && getSubPatternFromSpec(spec3, basePath, usage, wildcardMatchers[usage]); return pattern && `^(?:${pattern})${usage === "exclude" ? "(?:$|/)" : "$"}`; } - function getSubPatternFromSpec(spec2, basePath, usage, { singleAsteriskRegexFragment, doubleAsteriskRegexFragment, replaceWildcardCharacter: replaceWildcardCharacter2 } = wildcardMatchers[usage]) { + function getSubPatternFromSpec(spec3, basePath, usage, { singleAsteriskRegexFragment, doubleAsteriskRegexFragment, replaceWildcardCharacter: replaceWildcardCharacter2 } = wildcardMatchers[usage]) { let subpattern = ""; let hasWrittenComponent = false; - const components = getNormalizedPathComponents(spec2, basePath); + const components = getNormalizedPathComponents(spec3, basePath); const lastComponent = last(components); if (usage !== "exclude" && lastComponent === "**") { return; @@ -40722,8 +40753,8 @@ ${lanes.join(` const wildcardFiles = arrayFrom(wildcardFileMap.values()); return literalFiles.concat(wildcardFiles, arrayFrom(wildCardJsonFileMap.values())); } - function isExcludedFile(pathToCheck, spec2, basePath, useCaseSensitiveFileNames2, currentDirectory) { - const { validatedFilesSpec, validatedIncludeSpecs, validatedExcludeSpecs } = spec2; + function isExcludedFile(pathToCheck, spec3, basePath, useCaseSensitiveFileNames2, currentDirectory) { + const { validatedFilesSpec, validatedIncludeSpecs, validatedExcludeSpecs } = spec3; if (!length(validatedIncludeSpecs) || !length(validatedExcludeSpecs)) return false; basePath = normalizePath(basePath); @@ -40745,7 +40776,7 @@ ${lanes.join(` return lastDotIndex > wildcardIndex; } function matchesExclude(pathToCheck, excludeSpecs, useCaseSensitiveFileNames2, currentDirectory) { - return matchesExcludeWorker(pathToCheck, filter(excludeSpecs, (spec2) => !invalidDotDotAfterRecursiveWildcard(spec2)), useCaseSensitiveFileNames2, currentDirectory); + return matchesExcludeWorker(pathToCheck, filter(excludeSpecs, (spec3) => !invalidDotDotAfterRecursiveWildcard(spec3)), useCaseSensitiveFileNames2, currentDirectory); } function matchesExcludeWorker(pathToCheck, excludeSpecs, useCaseSensitiveFileNames2, currentDirectory, basePath) { const excludePattern = getRegularExpressionForWildcard(excludeSpecs, combinePaths(normalizePath(currentDirectory), basePath), "exclude"); @@ -40757,26 +40788,26 @@ ${lanes.join(` return !hasExtension(pathToCheck) && excludeRegex.test(ensureTrailingDirectorySeparator(pathToCheck)); } function validateSpecs(specs, errors, disallowTrailingRecursion, jsonSourceFile, specKey) { - return specs.filter((spec2) => { - if (!isString(spec2)) + return specs.filter((spec3) => { + if (!isString(spec3)) return false; - const diag2 = specToDiagnostic(spec2, disallowTrailingRecursion); + const diag2 = specToDiagnostic(spec3, disallowTrailingRecursion); if (diag2 !== undefined) { errors.push(createDiagnostic(...diag2)); } return diag2 === undefined; }); - function createDiagnostic(message, spec2) { - const element = getTsConfigPropArrayElementValue(jsonSourceFile, specKey, spec2); - return createDiagnosticForNodeInSourceFileOrCompilerDiagnostic(jsonSourceFile, element, message, spec2); + function createDiagnostic(message, spec3) { + const element = getTsConfigPropArrayElementValue(jsonSourceFile, specKey, spec3); + return createDiagnosticForNodeInSourceFileOrCompilerDiagnostic(jsonSourceFile, element, message, spec3); } } - function specToDiagnostic(spec2, disallowTrailingRecursion) { - Debug.assert(typeof spec2 === "string"); - if (disallowTrailingRecursion && invalidTrailingRecursionPattern.test(spec2)) { - return [Diagnostics.File_specification_cannot_end_in_a_recursive_directory_wildcard_Asterisk_Asterisk_Colon_0, spec2]; - } else if (invalidDotDotAfterRecursiveWildcard(spec2)) { - return [Diagnostics.File_specification_cannot_contain_a_parent_directory_that_appears_after_a_recursive_directory_wildcard_Asterisk_Asterisk_Colon_0, spec2]; + function specToDiagnostic(spec3, disallowTrailingRecursion) { + Debug.assert(typeof spec3 === "string"); + if (disallowTrailingRecursion && invalidTrailingRecursionPattern.test(spec3)) { + return [Diagnostics.File_specification_cannot_end_in_a_recursive_directory_wildcard_Asterisk_Asterisk_Colon_0, spec3]; + } else if (invalidDotDotAfterRecursiveWildcard(spec3)) { + return [Diagnostics.File_specification_cannot_contain_a_parent_directory_that_appears_after_a_recursive_directory_wildcard_Asterisk_Asterisk_Colon_0, spec3]; } } function getWildcardDirectories({ validatedIncludeSpecs: include, validatedExcludeSpecs: exclude }, basePath, useCaseSensitiveFileNames2) { @@ -40787,11 +40818,11 @@ ${lanes.join(` if (include !== undefined) { const recursiveKeys = []; for (const file of include) { - const spec2 = normalizePath(combinePaths(basePath, file)); - if (excludeRegex && excludeRegex.test(spec2)) { + const spec3 = normalizePath(combinePaths(basePath, file)); + if (excludeRegex && excludeRegex.test(spec3)) { continue; } - const match = getWildcardDirectoryFromSpec(spec2, useCaseSensitiveFileNames2); + const match = getWildcardDirectoryFromSpec(spec3, useCaseSensitiveFileNames2); if (match) { const { key, path, flags } = match; const existingPath = wildCardKeyToPath.get(key); @@ -40822,20 +40853,20 @@ ${lanes.join(` function toCanonicalKey(path, useCaseSensitiveFileNames2) { return useCaseSensitiveFileNames2 ? path : toFileNameLowerCase(path); } - function getWildcardDirectoryFromSpec(spec2, useCaseSensitiveFileNames2) { - const match = wildcardDirectoryPattern.exec(spec2); + function getWildcardDirectoryFromSpec(spec3, useCaseSensitiveFileNames2) { + const match = wildcardDirectoryPattern.exec(spec3); if (match) { - const questionWildcardIndex = spec2.indexOf("?"); - const starWildcardIndex = spec2.indexOf("*"); - const lastDirectorySeperatorIndex = spec2.lastIndexOf(directorySeparator); + const questionWildcardIndex = spec3.indexOf("?"); + const starWildcardIndex = spec3.indexOf("*"); + const lastDirectorySeperatorIndex = spec3.lastIndexOf(directorySeparator); return { key: toCanonicalKey(match[0], useCaseSensitiveFileNames2), path: match[0], flags: questionWildcardIndex !== -1 && questionWildcardIndex < lastDirectorySeperatorIndex || starWildcardIndex !== -1 && starWildcardIndex < lastDirectorySeperatorIndex ? 1 : 0 }; } - if (isImplicitGlob(spec2.substring(spec2.lastIndexOf(directorySeparator) + 1))) { - const path = removeTrailingDirectorySeparator(spec2); + if (isImplicitGlob(spec3.substring(spec3.lastIndexOf(directorySeparator) + 1))) { + const path = removeTrailingDirectorySeparator(spec3); return { key: toCanonicalKey(path, useCaseSensitiveFileNames2), path, @@ -45962,7 +45993,7 @@ ${lanes.join(` } } var ts_moduleSpecifiers_exports = {}; - __export(ts_moduleSpecifiers_exports, { + __export2(ts_moduleSpecifiers_exports, { RelativePreference: () => RelativePreference, countPathComponents: () => countPathComponents, forEachFileNameOfModule: () => forEachFileNameOfModule, @@ -118075,7 +118106,7 @@ ${lanes.join(` } } var ts_JsTyping_exports = {}; - __export(ts_JsTyping_exports, { + __export2(ts_JsTyping_exports, { NameValidationResult: () => NameValidationResult, discoverTypings: () => discoverTypings, isTypingUpToDate: () => isTypingUpToDate, @@ -121532,8 +121563,8 @@ ${lanes.join(` } } function getIsExcludedPatterns(preferences, useCaseSensitiveFileNames2) { - return mapDefined(preferences.autoImportFileExcludePatterns, (spec2) => { - const pattern = getSubPatternFromSpec(spec2, "", "exclude"); + return mapDefined(preferences.autoImportFileExcludePatterns, (spec3) => { + const pattern = getSubPatternFromSpec(spec3, "", "exclude"); return pattern ? getRegexFromPattern(pattern, useCaseSensitiveFileNames2) : undefined; }); } @@ -124366,7 +124397,7 @@ interface Symbol { return options; } var ts_NavigateTo_exports = {}; - __export(ts_NavigateTo_exports, { + __export2(ts_NavigateTo_exports, { getNavigateToItems: () => getNavigateToItems }); function getNavigateToItems(sourceFiles, checker, cancellationToken, searchValue, maxResultCount, excludeDtsFiles, excludeLibFiles, program) { @@ -124471,7 +124502,7 @@ interface Symbol { }; } var ts_NavigationBar_exports = {}; - __export(ts_NavigationBar_exports, { + __export2(ts_NavigationBar_exports, { getNavigationBarItems: () => getNavigationBarItems, getNavigationTree: () => getNavigationTree }); @@ -125200,7 +125231,7 @@ interface Symbol { return text.replace(/\\?(?:\r?\n|[\r\u2028\u2029])/g, ""); } var ts_refactor_exports = {}; - __export(ts_refactor_exports, { + __export2(ts_refactor_exports, { addExportsInOldFile: () => addExportsInOldFile, addImportsForMovedSymbols: () => addImportsForMovedSymbols, addNewFileToTsconfig: () => addNewFileToTsconfig, @@ -125345,8 +125376,8 @@ interface Symbol { if (wasDefault) { if (isExportAssignment(exportNode) && !exportNode.isExportEquals) { const exp = exportNode.expression; - const spec2 = makeExportSpecifier(exp.text, exp.text); - changes.replaceNode(exportingSourceFile, exportNode, factory.createExportDeclaration(undefined, false, factory.createNamedExports([spec2]))); + const spec3 = makeExportSpecifier(exp.text, exp.text); + changes.replaceNode(exportingSourceFile, exportNode, factory.createExportDeclaration(undefined, false, factory.createNamedExports([spec3]))); } else { changes.delete(exportingSourceFile, Debug.checkDefined(findModifier(exportNode, 90), "Should find a default keyword in modifier list")); } @@ -125397,17 +125428,17 @@ interface Symbol { break; case 277: case 282: { - const spec2 = parent2; - changes.replaceNode(importingSourceFile, spec2, makeImportSpecifier(exportName, spec2.name.text)); + const spec3 = parent2; + changes.replaceNode(importingSourceFile, spec3, makeImportSpecifier(exportName, spec3.name.text)); break; } case 274: { const clause = parent2; Debug.assert(clause.name === ref, "Import clause name should match provided ref"); - const spec2 = makeImportSpecifier(exportName, ref.text); + const spec3 = makeImportSpecifier(exportName, ref.text); const { namedBindings } = clause; if (!namedBindings) { - changes.replaceNode(importingSourceFile, ref, factory.createNamedImports([spec2])); + changes.replaceNode(importingSourceFile, ref, factory.createNamedImports([spec3])); } else if (namedBindings.kind === 275) { changes.deleteRange(importingSourceFile, { pos: ref.getStart(importingSourceFile), end: namedBindings.getStart(importingSourceFile) }); const quotePreference = isStringLiteral(clause.parent.moduleSpecifier) ? quotePreferenceFromString(clause.parent.moduleSpecifier, importingSourceFile) : 1; @@ -125415,7 +125446,7 @@ interface Symbol { changes.insertNodeAfter(importingSourceFile, clause.parent, newImport); } else { changes.delete(importingSourceFile, ref); - changes.insertNodeAtEndOfList(importingSourceFile, namedBindings.elements, spec2); + changes.insertNodeAtEndOfList(importingSourceFile, namedBindings.elements, spec3); } break; } @@ -128196,7 +128227,7 @@ ${newComment.split(` } } var ts_refactor_extractSymbol_exports = {}; - __export(ts_refactor_extractSymbol_exports, { + __export2(ts_refactor_extractSymbol_exports, { Messages: () => Messages, RangeFacts: () => RangeFacts, getRangeToExtract: () => getRangeToExtract2, @@ -132122,7 +132153,7 @@ ${newComment.split(` return result; } var ts_BreakpointResolver_exports = {}; - __export(ts_BreakpointResolver_exports, { + __export2(ts_BreakpointResolver_exports, { spanInSourceFileAtLocation: () => spanInSourceFileAtLocation }); function spanInSourceFileAtLocation(sourceFile, position) { @@ -132600,7 +132631,7 @@ ${newComment.split(` } } var ts_CallHierarchy_exports = {}; - __export(ts_CallHierarchy_exports, { + __export2(ts_CallHierarchy_exports, { createCallHierarchyItem: () => createCallHierarchyItem, getIncomingCalls: () => getIncomingCalls, getOutgoingCalls: () => getOutgoingCalls, @@ -133009,11 +133040,11 @@ ${newComment.split(` return group(collectCallSites(program, declaration), getCallSiteGroupKey, (entries) => convertCallSiteGroupToOutgoingCall(program, entries)); } var ts_classifier_exports = {}; - __export(ts_classifier_exports, { + __export2(ts_classifier_exports, { v2020: () => ts_classifier_v2020_exports }); var ts_classifier_v2020_exports = {}; - __export(ts_classifier_v2020_exports, { + __export2(ts_classifier_v2020_exports, { TokenEncodingConsts: () => TokenEncodingConsts, TokenModifier: () => TokenModifier, TokenType: () => TokenType, @@ -133021,7 +133052,7 @@ ${newComment.split(` getSemanticClassifications: () => getSemanticClassifications2 }); var ts_codefix_exports = {}; - __export(ts_codefix_exports, { + __export2(ts_codefix_exports, { PreserveOptionalFlags: () => PreserveOptionalFlags, addNewNodeForMemberSymbol: () => addNewNodeForMemberSymbol, codeFixAll: () => codeFixAll, @@ -136454,13 +136485,13 @@ ${newComment.split(` changes.replaceNode(sourceFile, clause.namedBindings, factory.updateNamedImports(clause.namedBindings, toSorted([...existingSpecifiers.filter((s) => !removeExistingImportSpecifiers.has(s)), ...newSpecifiers], specifierComparer))); } else if ((existingSpecifiers == null ? undefined : existingSpecifiers.length) && isSorted !== false) { const transformedExistingSpecifiers = promoteFromTypeOnly2 && existingSpecifiers ? factory.updateNamedImports(clause.namedBindings, sameMap(existingSpecifiers, (e) => factory.updateImportSpecifier(e, true, e.propertyName, e.name))).elements : existingSpecifiers; - for (const spec2 of newSpecifiers) { - const insertionIndex = ts_OrganizeImports_exports.getImportSpecifierInsertionIndex(transformedExistingSpecifiers, spec2, specifierComparer); - changes.insertImportSpecifierAtIndex(sourceFile, spec2, clause.namedBindings, insertionIndex); + for (const spec3 of newSpecifiers) { + const insertionIndex = ts_OrganizeImports_exports.getImportSpecifierInsertionIndex(transformedExistingSpecifiers, spec3, specifierComparer); + changes.insertImportSpecifierAtIndex(sourceFile, spec3, clause.namedBindings, insertionIndex); } } else if (existingSpecifiers == null ? undefined : existingSpecifiers.length) { - for (const spec2 of newSpecifiers) { - changes.insertNodeInListAfter(sourceFile, last(existingSpecifiers), spec2, existingSpecifiers); + for (const spec3 of newSpecifiers) { + changes.insertNodeInListAfter(sourceFile, last(existingSpecifiers), spec3, existingSpecifiers); } } else { if (newSpecifiers.length) { @@ -142220,7 +142251,7 @@ ${newComment.split(` } } var ts_Completions_exports = {}; - __export(ts_Completions_exports, { + __export2(ts_Completions_exports, { CompletionKind: () => CompletionKind, CompletionSource: () => CompletionSource, SortText: () => SortText, @@ -145770,7 +145801,7 @@ ${newComment.split(` return keyword === "abstract" || keyword === "async" || keyword === "await" || keyword === "declare" || keyword === "module" || keyword === "namespace" || keyword === "type" || keyword === "satisfies" || keyword === "as"; } var ts_Completions_StringCompletions_exports = {}; - __export(ts_Completions_StringCompletions_exports, { + __export2(ts_Completions_StringCompletions_exports, { getStringLiteralCompletionDetails: () => getStringLiteralCompletionDetails, getStringLiteralCompletions: () => getStringLiteralCompletions }); @@ -146603,7 +146634,7 @@ ${newComment.split(` return isCallExpression(node.parent) && firstOrUndefined(node.parent.arguments) === node && isIdentifier(node.parent.expression) && node.parent.expression.escapedText === "require"; } var ts_FindAllReferences_exports = {}; - __export(ts_FindAllReferences_exports, { + __export2(ts_FindAllReferences_exports, { Core: () => Core, DefinitionKind: () => DefinitionKind, EntryKind: () => EntryKind, @@ -148818,7 +148849,7 @@ ${newComment.split(` } })(Core || (Core = {})); var ts_GoToDefinition_exports = {}; - __export(ts_GoToDefinition_exports, { + __export2(ts_GoToDefinition_exports, { createDefinitionInfo: () => createDefinitionInfo, getDefinitionAndBoundSpan: () => getDefinitionAndBoundSpan, getDefinitionAtPosition: () => getDefinitionAtPosition, @@ -149314,7 +149345,7 @@ ${newComment.split(` } } var ts_InlayHints_exports = {}; - __export(ts_InlayHints_exports, { + __export2(ts_InlayHints_exports, { provideInlayHints: () => provideInlayHints }); var leadingParameterNameCommentRegexFactory = (name) => { @@ -150076,7 +150107,7 @@ ${newComment.split(` } } var ts_JsDoc_exports = {}; - __export(ts_JsDoc_exports, { + __export2(ts_JsDoc_exports, { getDocCommentTemplateAtPosition: () => getDocCommentTemplateAtPosition, getJSDocParameterNameCompletionDetails: () => getJSDocParameterNameCompletionDetails, getJSDocParameterNameCompletions: () => getJSDocParameterNameCompletions, @@ -150496,7 +150527,7 @@ ${newComment.split(` } } var ts_MapCode_exports = {}; - __export(ts_MapCode_exports, { + __export2(ts_MapCode_exports, { mapCode: () => mapCode }); function mapCode(sourceFile, contents, focusLocations, host, formatContext, preferences) { @@ -150631,7 +150662,7 @@ ${content} node.forEachChild(resetNodePositions); } var ts_OrganizeImports_exports = {}; - __export(ts_OrganizeImports_exports, { + __export2(ts_OrganizeImports_exports, { compareImportsOrRequireStatements: () => compareImportsOrRequireStatements, compareModuleSpecifiers: () => compareModuleSpecifiers2, getImportDeclarationInsertionIndex: () => getImportDeclarationInsertionIndex, @@ -151214,7 +151245,7 @@ ${content} return compareModuleSpecifiersWorker(m1, m2, comparer); } var ts_OutliningElementsCollector_exports = {}; - __export(ts_OutliningElementsCollector_exports, { + __export2(ts_OutliningElementsCollector_exports, { collectElements: () => collectElements }); function collectElements(sourceFile, cancellationToken) { @@ -151532,7 +151563,7 @@ ${content} return findChildOfKind(body, 19, sourceFile); } var ts_Rename_exports = {}; - __export(ts_Rename_exports, { + __export2(ts_Rename_exports, { getRenameInfo: () => getRenameInfo, nodeIsEligibleForRename: () => nodeIsEligibleForRename }); @@ -151686,7 +151717,7 @@ ${content} } } var ts_SignatureHelp_exports = {}; - __export(ts_SignatureHelp_exports, { + __export2(ts_SignatureHelp_exports, { getArgumentInfoForCompletions: () => getArgumentInfoForCompletions, getSignatureHelpItems: () => getSignatureHelpItems }); @@ -152160,7 +152191,7 @@ ${content} return { name: typeParameter.symbol.name, documentation: typeParameter.symbol.getDocumentationComment(checker), displayParts, isOptional: false, isRest: false }; } var ts_SmartSelectionRange_exports = {}; - __export(ts_SmartSelectionRange_exports, { + __export2(ts_SmartSelectionRange_exports, { getSmartSelectionRange: () => getSmartSelectionRange }); function getSmartSelectionRange(pos, sourceFile) { @@ -152352,7 +152383,7 @@ ${content} } } var ts_SymbolDisplay_exports = {}; - __export(ts_SymbolDisplay_exports, { + __export2(ts_SymbolDisplay_exports, { getSymbolDisplayPartsDocumentationAndSymbolKind: () => getSymbolDisplayPartsDocumentationAndSymbolKind, getSymbolKind: () => getSymbolKind, getSymbolModifiers: () => getSymbolModifiers @@ -153058,7 +153089,7 @@ ${content} }); } var ts_textChanges_exports = {}; - __export(ts_textChanges_exports, { + __export2(ts_textChanges_exports, { ChangeTracker: () => ChangeTracker, LeadingTriviaOption: () => LeadingTriviaOption, TrailingTriviaOption: () => TrailingTriviaOption, @@ -154255,7 +154286,7 @@ ${options.prefix}` : ` }); } var ts_formatting_exports = {}; - __export(ts_formatting_exports, { + __export2(ts_formatting_exports, { FormattingContext: () => FormattingContext, FormattingRequestKind: () => FormattingRequestKind, RuleAction: () => RuleAction, @@ -156640,7 +156671,7 @@ ${options.prefix}` : ` } })(SmartIndenter || (SmartIndenter = {})); var ts_preparePasteEdits_exports = {}; - __export(ts_preparePasteEdits_exports, { + __export2(ts_preparePasteEdits_exports, { preparePasteEdits: () => preparePasteEdits }); function preparePasteEdits(sourceFile, copiedFromRange, checker) { @@ -156672,7 +156703,7 @@ ${options.prefix}` : ` return shouldProvidePasteEdits; } var ts_PasteEdits_exports = {}; - __export(ts_PasteEdits_exports, { + __export2(ts_PasteEdits_exports, { pasteEditsProvider: () => pasteEditsProvider }); var fixId55 = "providePostPasteEdits"; @@ -156763,7 +156794,7 @@ ${options.prefix}` : ` }; } var ts_exports2 = {}; - __export(ts_exports2, { + __export2(ts_exports2, { ANONYMOUS: () => ANONYMOUS, AccessFlags: () => AccessFlags, AssertionLevel: () => AssertionLevel, @@ -159104,7 +159135,7 @@ ${options.prefix}` : ` }; } var ts_server_exports3 = {}; - __export(ts_server_exports3, { + __export2(ts_server_exports3, { ActionInvalidate: () => ActionInvalidate, ActionPackageInstalled: () => ActionPackageInstalled, ActionSet: () => ActionSet, @@ -159202,7 +159233,7 @@ ${options.prefix}` : ` updateProjectIfDirty: () => updateProjectIfDirty }); var ts_server_typingsInstaller_exports = {}; - __export(ts_server_typingsInstaller_exports, { + __export2(ts_server_typingsInstaller_exports, { TypingsInstaller: () => TypingsInstaller, getNpmCommandForInstallation: () => getNpmCommandForInstallation, installNpmPackages: () => installNpmPackages, @@ -159757,7 +159788,7 @@ ${options.prefix}` : ` return base === "tsconfig.json" || base === "jsconfig.json" ? base : undefined; } var ts_server_protocol_exports = {}; - __export(ts_server_protocol_exports, { + __export2(ts_server_protocol_exports, { ClassificationType: () => ClassificationType, CommandTypes: () => CommandTypes, CompletionTriggerKind: () => CompletionTriggerKind, @@ -169359,7 +169390,7 @@ Additional information: BADCLIENT: Bad error code, ${badCode} not found in range _TypingsInstallerAdapter.requestDelayMillis = 100; var TypingsInstallerAdapter = _TypingsInstallerAdapter; var ts_server_exports4 = {}; - __export(ts_server_exports4, { + __export2(ts_server_exports4, { ActionInvalidate: () => ActionInvalidate, ActionPackageInstalled: () => ActionPackageInstalled, ActionSet: () => ActionSet, @@ -169485,10 +169516,10 @@ Additional information: BADCLIENT: Bad error code, ${badCode} not found in range // node_modules/.bun/@vscode+l10n@0.0.18/node_modules/@vscode/l10n/dist/main.js var require_main = __commonJS((exports2, module2) => { var __defProp2 = Object.defineProperty; - var __getOwnPropDesc = Object.getOwnPropertyDescriptor; + var __getOwnPropDesc2 = Object.getOwnPropertyDescriptor; var __getOwnPropNames2 = Object.getOwnPropertyNames; var __hasOwnProp2 = Object.prototype.hasOwnProperty; - var __export = (target, all) => { + var __export2 = (target, all) => { for (var name in all) __defProp2(target, name, { get: all[name], enumerable: true }); }; @@ -169496,17 +169527,17 @@ var require_main = __commonJS((exports2, module2) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames2(from)) if (!__hasOwnProp2.call(to, key) && key !== except) - __defProp2(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + __defProp2(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc2(from, key)) || desc.enumerable }); } return to; }; - var __toCommonJS = (mod) => __copyProps(__defProp2({}, "__esModule", { value: true }), mod); + var __toCommonJS2 = (mod) => __copyProps(__defProp2({}, "__esModule", { value: true }), mod); var main_exports = {}; - __export(main_exports, { + __export2(main_exports, { config: () => config, t: () => t }); - module2.exports = __toCommonJS(main_exports); + module2.exports = __toCommonJS2(main_exports); var import_fs = require("fs"); var import_promises = require("fs/promises"); async function readFileFromUri(uri) { @@ -169624,6 +169655,14 @@ var require_main = __commonJS((exports2, module2) => { } }); +// packages/language-server/src/server.ts +var exports_server = {}; +__export(exports_server, { + apiCallHover: () => apiCallHover, + apiCallCompletions: () => apiCallCompletions +}); +module.exports = __toCommonJS(exports_server); + // packages/syntax/src/tokenizer.ts class LexError extends Error { } @@ -196164,6 +196203,46 @@ function clearHtmlRegionCache(uri) { } // packages/language-server/src/server.ts +function dataApisOf(source) { + try { + return parse(source).dataApis; + } catch { + return []; + } +} +function apiDetail(block) { + return `${block.method} ${block.path}`; +} +function apiCallCompletions(source) { + return dataApisOf(source).map((block) => ({ + label: block.name, + kind: 2, + detail: apiDetail(block), + documentation: block.sections ? [ + block.sections.body.length ? `body: ${block.sections.body.map((field) => `${field.name}${field.optional ? "?" : ""}: ${field.type}`).join(", ")}` : undefined + ].filter(Boolean).join(` +`) : undefined + })); +} +function apiCallHover(source, name) { + const block = dataApisOf(source).find((entry) => entry.name === name); + if (!block) + return; + const lines = [`**${block.name}** \`${block.method} ${block.path}\``]; + if (block.sections?.body.length) { + lines.push(`request body: ${block.sections.body.map((field) => `${field.name}${field.optional ? "?" : ""}: ${field.type}`).join(", ")}`); + } + if (block.sections?.parameters.length) { + lines.push(`parameters: ${block.sections.parameters.map((field) => `${field.name}${field.optional ? "?" : ""}: ${field.type}`).join(", ")}`); + } + return lines.join(` + +`); +} +function isApiCallPosition(text, offset) { + const before = text.slice(0, offset); + return /\bapi\.\w*$/.test(before); +} var documents = new Map; var diagnosticTimers = new Map; var MAX_OPEN_DOCUMENTS = 256; @@ -196336,6 +196415,10 @@ async function handle(message) { } case "textDocument/completion": { const document = documents.get(params.textDocument.uri); + if (document && isApiCallPosition(document.text, offsetAt(document.text, params.position))) { + result(message.id, apiCallCompletions(document.text)); + break; + } const wrnexus = [...completionItems(), ...workspaceCompletionItems(workspaceRoot)]; const html = document ? htmlCompletions(document, params.position) : []; result(message.id, html.length ? mergeCompletions(wrnexus, html) : wrnexus); @@ -196353,6 +196436,12 @@ async function handle(message) { } case "textDocument/hover": { const document = documents.get(params.textDocument.uri); + const word = document ? wordAt(document.text, params.position)?.word : undefined; + const apiHover = word ? apiCallHover(document.text, word) : undefined; + if (apiHover) { + result(message.id, { contents: apiHover }); + break; + } const html = document ? htmlHover(document, params.position) : null; result(message.id, html ?? (document ? hover(document, params.position) : null)); break; diff --git a/editors/vscode/test/grammar-apis.test.js b/editors/vscode/test/grammar-apis.test.js index 9329b562..d5b6b009 100644 --- a/editors/vscode/test/grammar-apis.test.js +++ b/editors/vscode/test/grammar-apis.test.js @@ -106,9 +106,6 @@ test('REGRESSION: runtime = "client" still highlights via execution-decl', () => test("REGRESSION: the client function modifier still highlights via functions-block", () => { const rule = ruleByTopLevelBlockName("functions-block"); - const modifierPattern = rule.patterns.find( - (p) => (p.match && /storage\.modifier\.async/.test("")) || p.match, - ); const matchRe = new RegExp( rule.patterns.find( (p) => p.captures && p.captures["1"]?.name === "storage.modifier.runtime.wrn", diff --git a/examples/crm-app/.gitignore b/examples/crm-app/.gitignore new file mode 100644 index 00000000..0b671cc6 --- /dev/null +++ b/examples/crm-app/.gitignore @@ -0,0 +1,5 @@ +dist/ +.wrnexus/ +*.sqlite +*.sqlite-shm +*.sqlite-wal diff --git a/examples/crm-app/README.md b/examples/crm-app/README.md new file mode 100644 index 00000000..b316ba25 --- /dev/null +++ b/examples/crm-app/README.md @@ -0,0 +1,13 @@ +# Northstar CRM example + +A complete WrNexus example covering public pages, SQLite migrations and seed data, SQL-backed signup/login/session authentication, database-backed roles and permissions, protected pages, and permission-checked CRM APIs. + +```bash +bun run db:migrate +bun run db:seed +bun run dev +``` + +Open `/signup`, create an account, and the signup hook assigns the `sales-rep` role. The user is automatically signed in and redirected to `/dashboard`. Auth tables come from the `@wrnexus/auth` plugin migrations; CRM and authorization tables come from `app/db/migrations/001_crm.sql`. + +The seed records use the placeholder owner `demo-owner` to demonstrate repeatable data. To attach them to a registered user, update their `owner_id` to that user's ID. diff --git a/examples/crm-app/app/api/contacts.ts b/examples/crm-app/app/api/contacts.ts new file mode 100644 index 00000000..89af8ace --- /dev/null +++ b/examples/crm-app/app/api/contacts.ts @@ -0,0 +1,35 @@ +import { can } from "@wrnexus/authz"; +import { getDb } from "@wrnexus/db"; +import type { Context } from "@wrnexus/core"; +import { ensureCrmWorkspace } from "../lib/workspace.ts"; + +const userId = (ctx: Context) => String((ctx.user as { id?: unknown } | undefined)?.id ?? ""); + +export async function GET(ctx: Context): Promise { + if (!(await can(ctx, "contact:read"))) + return Response.json({ error: "Forbidden" }, { status: 403 }); + await ensureCrmWorkspace(userId(ctx)); + const rows = await getDb().all( + "SELECT id,name,email,company,phone,status,created_at FROM crm_contacts WHERE owner_id = ? ORDER BY name", + [userId(ctx)], + ); + return Response.json({ contacts: rows }); +} + +export async function POST(ctx: Context): Promise { + if (!(await can(ctx, "contact:write"))) + return Response.json({ error: "Forbidden" }, { status: 403 }); + const body = (await ctx.req.json()) as Record; + const name = String(body.name ?? "").trim(); + const email = String(body.email ?? "") + .trim() + .toLowerCase(); + if (!name || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + return Response.json({ error: "A name and valid email are required" }, { status: 400 }); + } + const result = await getDb().exec( + "INSERT INTO crm_contacts (owner_id,name,email,company,phone,status) VALUES (?,?,?,?,?,?)", + [userId(ctx), name, email, String(body.company ?? ""), String(body.phone ?? ""), "lead"], + ); + return Response.json({ id: Number(result.lastInsertId), name, email }, { status: 201 }); +} diff --git a/examples/crm-app/app/api/dashboard.ts b/examples/crm-app/app/api/dashboard.ts new file mode 100644 index 00000000..4b314a7b --- /dev/null +++ b/examples/crm-app/app/api/dashboard.ts @@ -0,0 +1,33 @@ +import { can } from "@wrnexus/authz"; +import { getDb } from "@wrnexus/db"; +import type { Context } from "@wrnexus/core"; +import { ensureCrmWorkspace } from "../lib/workspace.ts"; + +export async function GET(ctx: Context): Promise { + if (!(await can(ctx, "crm:dashboard"))) + return Response.json({ error: "Forbidden" }, { status: 403 }); + const owner = String((ctx.user as { id?: unknown } | undefined)?.id ?? ""); + await ensureCrmWorkspace(owner); + const metrics = await getDb().one<{ + pipeline_value_cents: number; + open_opportunities: number; + contacts: number; + }>( + `SELECT + COALESCE((SELECT SUM(value_cents) FROM crm_deals WHERE owner_id = ? AND stage NOT IN ('won','lost')), 0) pipeline_value_cents, + (SELECT COUNT(*) FROM crm_deals WHERE owner_id = ? AND stage NOT IN ('won','lost')) open_opportunities, + (SELECT COUNT(*) FROM crm_contacts WHERE owner_id = ?) contacts`, + [owner, owner, owner], + ); + return Response.json({ + metrics: { + pipelineValue: new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + maximumFractionDigits: 0, + }).format(Number(metrics?.pipeline_value_cents ?? 0) / 100), + openOpportunities: Number(metrics?.open_opportunities ?? 0), + contacts: Number(metrics?.contacts ?? 0), + }, + }); +} diff --git a/examples/crm-app/app/api/deals.ts b/examples/crm-app/app/api/deals.ts new file mode 100644 index 00000000..12bc7b0e --- /dev/null +++ b/examples/crm-app/app/api/deals.ts @@ -0,0 +1,15 @@ +import { can } from "@wrnexus/authz"; +import { getDb } from "@wrnexus/db"; +import type { Context } from "@wrnexus/core"; +import { ensureCrmWorkspace } from "../lib/workspace.ts"; + +export async function GET(ctx: Context): Promise { + if (!(await can(ctx, "deal:read"))) return Response.json({ error: "Forbidden" }, { status: 403 }); + const owner = String((ctx.user as { id?: unknown } | undefined)?.id ?? ""); + await ensureCrmWorkspace(owner); + const deals = await getDb().all( + "SELECT d.id,d.title,d.value_cents,d.stage,d.close_date,c.name contact_name FROM crm_deals d LEFT JOIN crm_contacts c ON c.id=d.contact_id WHERE d.owner_id=? ORDER BY d.updated_at DESC", + [owner], + ); + return Response.json({ deals }); +} diff --git a/examples/crm-app/app/authz/crm.ts b/examples/crm-app/app/authz/crm.ts new file mode 100644 index 00000000..d937c9c5 --- /dev/null +++ b/examples/crm-app/app/authz/crm.ts @@ -0,0 +1,19 @@ +import { defineAuthz } from "@wrnexus/authz"; + +export default defineAuthz({ + permissions: { + "crm:dashboard": { title: "View dashboard" }, + "contact:read": { title: "View contacts" }, + "contact:write": { title: "Create and edit contacts" }, + "contact:delete": { title: "Delete contacts", risk: "high" }, + "deal:read": { title: "View deals" }, + "deal:write": { title: "Create and edit deals" }, + "admin:access": { title: "Manage CRM access", risk: "high" }, + }, + roles: { + viewer: ["crm:dashboard", "contact:read", "deal:read"], + "sales-rep": ["role:viewer", "contact:write", "deal:write"], + manager: ["role:sales-rep", "contact:delete"], + admin: ["role:manager", "admin:access"], + }, +}); diff --git a/examples/crm-app/app/db/migrations/001_crm.sql b/examples/crm-app/app/db/migrations/001_crm.sql new file mode 100644 index 00000000..7f624fa3 --- /dev/null +++ b/examples/crm-app/app/db/migrations/001_crm.sql @@ -0,0 +1,65 @@ +-- +up +CREATE TABLE IF NOT EXISTS crm_contacts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + owner_id TEXT NOT NULL, + name TEXT NOT NULL, + email TEXT NOT NULL, + company TEXT NOT NULL DEFAULT '', + phone TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'lead' CHECK (status IN ('lead', 'customer', 'inactive')), + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS crm_contacts_owner_idx ON crm_contacts(owner_id); +CREATE UNIQUE INDEX IF NOT EXISTS crm_contacts_owner_email_uq ON crm_contacts(owner_id, email); + +CREATE TABLE IF NOT EXISTS crm_deals ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + owner_id TEXT NOT NULL, + contact_id INTEGER REFERENCES crm_contacts(id) ON DELETE SET NULL, + title TEXT NOT NULL, + value_cents INTEGER NOT NULL DEFAULT 0 CHECK (value_cents >= 0), + stage TEXT NOT NULL DEFAULT 'qualified' CHECK (stage IN ('qualified', 'proposal', 'won', 'lost')), + close_date TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS crm_deals_owner_idx ON crm_deals(owner_id); +CREATE INDEX IF NOT EXISTS crm_deals_contact_idx ON crm_deals(contact_id); + +CREATE TABLE IF NOT EXISTS crm_activities ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + actor_id TEXT NOT NULL, + entity_type TEXT NOT NULL CHECK (entity_type IN ('contact', 'deal', 'account')), + entity_id TEXT NOT NULL, + action TEXT NOT NULL, + details_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS crm_activities_entity_idx ON crm_activities(entity_type, entity_id); + +-- Database-backed authorization assignments. +CREATE TABLE IF NOT EXISTS _wrn_authz_assignment ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + subject_id VARCHAR(255) NOT NULL, + scope VARCHAR(255) NOT NULL DEFAULT '', + role VARCHAR(255) NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT _wrn_authz_assignment_unique UNIQUE (subject_id, scope, role) +); +CREATE TABLE IF NOT EXISTS _wrn_authz_grant ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + subject_id VARCHAR(255) NOT NULL, + scope VARCHAR(255) NOT NULL DEFAULT '', + permission VARCHAR(255) NOT NULL, + effect VARCHAR(16) NOT NULL CHECK (effect IN ('allow', 'deny')), + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT _wrn_authz_grant_unique UNIQUE (subject_id, scope, permission) +); + +-- +down +DROP TABLE IF EXISTS _wrn_authz_grant; +DROP TABLE IF EXISTS _wrn_authz_assignment; +DROP TABLE IF EXISTS crm_activities; +DROP TABLE IF EXISTS crm_deals; +DROP TABLE IF EXISTS crm_contacts; diff --git a/examples/crm-app/app/db/seed.ts b/examples/crm-app/app/db/seed.ts new file mode 100644 index 00000000..8049a105 --- /dev/null +++ b/examples/crm-app/app/db/seed.ts @@ -0,0 +1,23 @@ +import type { Db } from "@wrnexus/db"; + +export default async function seed(db: Db): Promise { + const owner = "demo-owner"; + await db.exec("DELETE FROM crm_activities"); + await db.exec("DELETE FROM crm_deals"); + await db.exec("DELETE FROM crm_contacts"); + await db.exec( + "INSERT INTO crm_contacts (owner_id,name,email,company,phone,status) VALUES (?,?,?,?,?,?)", + [owner, "Ada Lovelace", "ada@example.test", "Analytical Engines", "+1 555 0101", "customer"], + ); + await db.exec( + "INSERT INTO crm_contacts (owner_id,name,email,company,phone,status) VALUES (?,?,?,?,?,?)", + [owner, "Grace Hopper", "grace@example.test", "Compiler Labs", "+1 555 0102", "lead"], + ); + const contact = await db.one<{ id: number }>("SELECT id FROM crm_contacts WHERE email = ?", [ + "ada@example.test", + ]); + await db.exec( + "INSERT INTO crm_deals (owner_id,contact_id,title,value_cents,stage,close_date) VALUES (?,?,?,?,?,?)", + [owner, contact?.id ?? null, "Enterprise rollout", 12500000, "proposal", "2026-12-15"], + ); +} diff --git a/examples/crm-app/app/lib/auth.ts b/examples/crm-app/app/lib/auth.ts new file mode 100644 index 00000000..b592bdb7 --- /dev/null +++ b/examples/crm-app/app/lib/auth.ts @@ -0,0 +1,32 @@ +import { createAuthEngine, SqlAuthStore, type AuthStore } from "@wrnexus/auth"; +import { getDb } from "@wrnexus/db"; +import { dbPermissionStore } from "@wrnexus/authz/db"; +import { ensureCrmWorkspace } from "./workspace.ts"; + +// Config is imported before the runtime opens its configured database. This +// forwarding store resolves getDb() only when an auth operation actually runs. +const store = new Proxy({} as AuthStore, { + get(_target, property) { + const value = Reflect.get(new SqlAuthStore(getDb()), property); + return typeof value === "function" ? value.bind(new SqlAuthStore(getDb())) : value; + }, +}); + +export const auth = createAuthEngine({ + store, + secret: process.env.AUTH_SECRET ?? "northstar-crm-development-secret-change-me", + issuer: "Northstar CRM", + onSignedIn(ctx, returnTo) { + const destination = + returnTo?.startsWith("/") && !returnTo.startsWith("//") ? returnTo : "/dashboard"; + return Response.redirect(new URL(destination, ctx.url), 303); + }, + onSignedOut(ctx) { + return Response.redirect(new URL("/", ctx.url), 303); + }, + async onSuccessfulSignUp(_ctx, user) { + await dbPermissionStore(getDb()).assignRole(user.id, "sales-rep"); + await ensureCrmWorkspace(user.id); + return { autoSignIn: true, redirectTo: "/dashboard" }; + }, +}); diff --git a/examples/crm-app/app/lib/workspace.ts b/examples/crm-app/app/lib/workspace.ts new file mode 100644 index 00000000..ae09436d --- /dev/null +++ b/examples/crm-app/app/lib/workspace.ts @@ -0,0 +1,29 @@ +import { getDb } from "@wrnexus/db"; + +/** Give every real CRM user a useful workspace on first access. */ +export async function ensureCrmWorkspace(ownerId: string): Promise { + if (!ownerId) return; + const db = getDb(); + await db.exec( + "INSERT OR IGNORE INTO crm_contacts (owner_id,name,email,company,phone,status) VALUES (?,?,?,?,?,?)", + [ownerId, "Ada Lovelace", "ada@example.test", "Analytical Engines", "+1 555 0101", "customer"], + ); + await db.exec( + "INSERT OR IGNORE INTO crm_contacts (owner_id,name,email,company,phone,status) VALUES (?,?,?,?,?,?)", + [ownerId, "Grace Hopper", "grace@example.test", "Compiler Labs", "+1 555 0102", "lead"], + ); + const existingDeal = await db.one<{ count: number }>( + "SELECT COUNT(*) count FROM crm_deals WHERE owner_id = ?", + [ownerId], + ); + if (Number(existingDeal?.count ?? 0) === 0) { + const contact = await db.one<{ id: number }>( + "SELECT id FROM crm_contacts WHERE owner_id = ? AND email = ?", + [ownerId, "ada@example.test"], + ); + await db.exec( + "INSERT INTO crm_deals (owner_id,contact_id,title,value_cents,stage,close_date) VALUES (?,?,?,?,?,?)", + [ownerId, contact?.id ?? null, "Enterprise rollout", 12500000, "proposal", "2026-12-15"], + ); + } +} diff --git a/examples/crm-app/app/middleware/authz.ts b/examples/crm-app/app/middleware/authz.ts new file mode 100644 index 00000000..3ef7963e --- /dev/null +++ b/examples/crm-app/app/middleware/authz.ts @@ -0,0 +1,11 @@ +import { authzMiddleware, getAuthzCatalog } from "@wrnexus/authz"; +import { getDb } from "@wrnexus/db"; +import { dbPermissionStore } from "@wrnexus/authz/db"; +import type { Middleware } from "@wrnexus/core"; + +// Production imports middleware before it opens configured databases. Resolve +// the SQL-backed store on the first request, after runtime initialization. +const middleware: Middleware = (ctx, next) => + authzMiddleware({ catalog: getAuthzCatalog(), store: dbPermissionStore(getDb()) })(ctx, next); + +export default middleware; diff --git a/examples/crm-app/app/middleware/protected.ts b/examples/crm-app/app/middleware/protected.ts new file mode 100644 index 00000000..4e24176b --- /dev/null +++ b/examples/crm-app/app/middleware/protected.ts @@ -0,0 +1,18 @@ +import { requireAuth } from "@wrnexus/auth"; +import { can } from "@wrnexus/authz"; +import type { Middleware } from "@wrnexus/core"; + +const guard = requireAuth({ loginPath: "/login" }); +const protectedPrefixes = ["/dashboard", "/contacts", "/deals", "/admin"]; + +const middleware: Middleware = (ctx, next) => { + if (!protectedPrefixes.some((prefix) => ctx.url.pathname.startsWith(prefix))) return next(); + return guard(ctx, async () => { + if (ctx.url.pathname.startsWith("/admin") && !(await can(ctx, "admin:access"))) { + return Response.redirect(new URL("/forbidden", ctx.url), 303); + } + return next(); + }); +}; + +export default middleware; diff --git a/examples/crm-app/app/pages/admin.wrn b/examples/crm-app/app/pages/admin.wrn new file mode 100644 index 00000000..8911c073 --- /dev/null +++ b/examples/crm-app/app/pages/admin.wrn @@ -0,0 +1,5 @@ +page Admin { + state user = ctx.user + seo { title = "Administration" } + view {

Access administration

Manage roles and protect high-risk operations.

Role-based access

Signed in as {user.displayName || user.username}

This route requires the admin:access permission. Assign manager and administrator roles through the database-backed authorization store.

} +} diff --git a/examples/crm-app/app/pages/contacts.wrn b/examples/crm-app/app/pages/contacts.wrn new file mode 100644 index 00000000..dc1c8ebc --- /dev/null +++ b/examples/crm-app/app/pages/contacts.wrn @@ -0,0 +1,6 @@ +page Contacts { + apis { listContacts GET /api/contacts { response { return data.contacts } } } + load server contacts { return await api.listContacts() } + seo { title = "Contacts" } + view {

Contacts

Every relationship, scoped securely to its owner.

New contact
Customer directory{#if contacts.length}
    {#each contacts as contact}
  • {contact.name}{contact.company || contact.email}{contact.status}
  • {/each}
{/if}{#if !contacts.length}

No contacts yet

Your first customer relationship will appear here.

{/if}
} +} diff --git a/examples/crm-app/app/pages/dashboard.wrn b/examples/crm-app/app/pages/dashboard.wrn new file mode 100644 index 00000000..5c6189de --- /dev/null +++ b/examples/crm-app/app/pages/dashboard.wrn @@ -0,0 +1,8 @@ +page Dashboard { + state user = ctx.user + state dashboardMetrics = ctx.metrics + apis { dashboard GET /api/dashboard { response { return data.metrics } } } + load server metrics { return await api.dashboard() } + seo { title = "Dashboard" } + view {

Good to see you, {user.displayName || user.username}

Live figures from your private SQLite workspace.

View contacts
Pipeline value{dashboardMetrics.pipelineValue}
Open opportunities{dashboardMetrics.openOpportunities}
Total contacts{dashboardMetrics.contacts}
Server rendered

One secure data path

This page was rendered from authenticated API results on the server. No browser prefetch or client data request is needed to show these metrics.

} +} diff --git a/examples/crm-app/app/pages/deals.wrn b/examples/crm-app/app/pages/deals.wrn new file mode 100644 index 00000000..39dfc6bc --- /dev/null +++ b/examples/crm-app/app/pages/deals.wrn @@ -0,0 +1,6 @@ +page Deals { + apis { listDeals GET /api/deals { response { return data.deals } } } + load server deals { return await api.listDeals() } + seo { title = "Deals" } + view {

Sales pipeline

Focus on the opportunities most likely to move.

New deal
Open opportunities{#if deals.length}
    {#each deals as deal}
  • {deal.title}{deal.contact_name || "Unassigned contact"}{deal.stage}
  • {/each}
{/if}{#if !deals.length}

No open deals

Create a contact and turn the relationship into an opportunity.

{/if}
} +} diff --git a/examples/crm-app/app/pages/forbidden.wrn b/examples/crm-app/app/pages/forbidden.wrn new file mode 100644 index 00000000..095dd5c8 --- /dev/null +++ b/examples/crm-app/app/pages/forbidden.wrn @@ -0,0 +1,6 @@ +page Forbidden { + seo { title = "Access denied" } + view { +
Permission required

You do not have access to administration.

Your account is working correctly, but an administrator role is required for this page.

+ } +} diff --git a/examples/crm-app/app/pages/index.wrn b/examples/crm-app/app/pages/index.wrn new file mode 100644 index 00000000..8e9d3298 --- /dev/null +++ b/examples/crm-app/app/pages/index.wrn @@ -0,0 +1,31 @@ +page Home { + seo { title = "CRM that keeps sales moving" } + view { +
+ +
+
+ + Customer intelligence, simplified

Know every customer. Close every opportunity.

Bring contacts, conversations, and pipeline into one calm workspace built for teams that value clarity.

Pipeline value$125k+18% this month
Active contacts24832 need follow-up
Win rate42%Across qualified deals
Next action8 todayStay ahead of every promise
+
+
+
+ } + } diff --git a/examples/crm-app/app/pages/login.wrn b/examples/crm-app/app/pages/login.wrn new file mode 100644 index 00000000..4a48e621 --- /dev/null +++ b/examples/crm-app/app/pages/login.wrn @@ -0,0 +1,10 @@ +import AuthSplitLayout from "@wrnexus/ui/components/AuthSplitLayout.wrn" + +page Login { + seo { title = "Log in" } + view { + +
+
+ } +} diff --git a/examples/crm-app/app/pages/pricing.wrn b/examples/crm-app/app/pages/pricing.wrn new file mode 100644 index 00000000..9d8d5f35 --- /dev/null +++ b/examples/crm-app/app/pages/pricing.wrn @@ -0,0 +1,4 @@ +page Pricing { + seo { title = "Pricing" } + view {
Simple pricing

Start free. Scale when your team does.

Everything needed to evaluate a secure, full-stack CRM locally. Move to Team when you are ready to collaborate.

Starter$0

SQLite workspace, CRM flows, authentication and permissions.

Start building
Team$29

Per user/month with shared pipeline and administration.

} +} diff --git a/examples/crm-app/app/pages/signup.wrn b/examples/crm-app/app/pages/signup.wrn new file mode 100644 index 00000000..99e4d3bf --- /dev/null +++ b/examples/crm-app/app/pages/signup.wrn @@ -0,0 +1,10 @@ +import AuthSplitLayout from "@wrnexus/ui/components/AuthSplitLayout.wrn" + +page Signup { + seo { title = "Create account" } + view { + +
+
+ } +} diff --git a/examples/crm-app/app/routes.gen.ts b/examples/crm-app/app/routes.gen.ts new file mode 100644 index 00000000..78a17a1a --- /dev/null +++ b/examples/crm-app/app/routes.gen.ts @@ -0,0 +1,116 @@ +// AUTO-GENERATED by `wrnexus dev` - do not edit. +// Typed routes support required, optional, and catch-all parameters. + +export interface Routes { + "/": Record; + "/admin": Record; + "/contacts": Record; + "/dashboard": Record; + "/deals": Record; + "/forbidden": Record; + "/login": Record; + "/pricing": Record; + "/signup": Record; +} + +export interface RouteNames { + "index": "/"; + "admin": "/admin"; + "contacts": "/contacts"; + "dashboard": "/dashboard"; + "deals": "/deals"; + "forbidden": "/forbidden"; + "login": "/login"; + "pricing": "/pricing"; + "signup": "/signup"; +} + +export interface RouteQueries { + [path: string]: Record; +} + +export type RoutePath = keyof Routes; +export type RouteName = keyof RouteNames; +export type RouteQuery

= P extends keyof RouteQueries + ? RouteQueries[P] + : Record; +type RouteValue = string | readonly string[] | undefined; + +function encodeRouteValue(value: RouteValue, catchAll: boolean): string { + if (value === undefined) return ""; + const values = Array.isArray(value) ? value : catchAll ? String(value).split("/") : [String(value)]; + return values.map((part) => encodeURIComponent(part)).join("/"); +} + +function buildHref(path: string, params: Record = {}): string { + const output: string[] = []; + for (const segment of path.split("/").filter(Boolean)) { + let name: string | undefined; + let optional = false; + let catchAll = false; + if (segment.startsWith("[[") && segment.endsWith("]]")) { + optional = true; + name = segment.slice(2, -2); + } else if (segment.startsWith("[") && segment.endsWith("]")) { + name = segment.slice(1, -1); + if (name.endsWith("?")) { + optional = true; + name = name.slice(0, -1); + } + } + if (!name) { + output.push(segment); + continue; + } + if (name.startsWith("...")) { + catchAll = true; + name = name.slice(3); + } + const value = params[name]; + if (value === undefined && optional) continue; + if (value === undefined) throw new Error(`WRN-ROUTE-MISSING-PARAM: Missing route parameter '${name}'.`); + output.push(encodeRouteValue(value, catchAll)); + } + return "/" + output.filter(Boolean).join("/"); +} + +export function href

( + path: P, + ...args: keyof Routes[P] extends never + ? [] + : Record extends Routes[P] + ? [params?: Routes[P]] + : [params: Routes[P]] +): string { + const params = (args[0] ?? {}) as Record; + return buildHref(String(path), params); +} + +export function route( + name: N, + ...args: keyof Routes[RouteNames[N]] extends never + ? [params?: Routes[RouteNames[N]], query?: RouteQuery] + : Record extends Routes[RouteNames[N]] + ? [params?: Routes[RouteNames[N]], query?: RouteQuery] + : [params: Routes[RouteNames[N]], query?: RouteQuery] +): string { + const paths: Record = { + "index": "/", + "admin": "/admin", + "contacts": "/contacts", + "dashboard": "/dashboard", + "deals": "/deals", + "forbidden": "/forbidden", + "login": "/login", + "pricing": "/pricing", + "signup": "/signup" +} as Record; + const output = buildHref(paths[name], (args[0] ?? {}) as Record); + const query = args[1]; + if (!query) return output; + const search = new URLSearchParams(); + for (const [key, value] of Object.entries(query)) + if (value !== undefined && value !== null) search.set(key, String(value)); + const text = search.toString(); + return text ? `${output}?${text}` : output; +} diff --git a/examples/crm-app/app/styles/global.css b/examples/crm-app/app/styles/global.css new file mode 100644 index 00000000..a4d3b4b8 --- /dev/null +++ b/examples/crm-app/app/styles/global.css @@ -0,0 +1,375 @@ +@import "tailwindcss"; +@plugin "@iconify/tailwind4"; +@source "../**/*.wrn"; +@source "../../../packages/auth/components/*.wrn"; +@source "../../../packages/ui/components/*.wrn"; + +:root { + color-scheme: light; +} +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} +html { + min-width: 320px; + background: var(--wrn-color-bg); +} +body { + color: var(--wrn-color-text); + background: var(--wrn-color-bg); + font-family: Inter, "Plus Jakarta Sans", ui-sans-serif, system-ui, sans-serif; + -webkit-font-smoothing: antialiased; +} +a { + color: inherit; + text-decoration: none; +} + +.crm-public { + min-height: 100dvh; + overflow: hidden; + background: + radial-gradient( + circle at 80% 10%, + color-mix(in srgb, var(--wrn-color-primary) 15%, transparent), + transparent 28% + ), + var(--wrn-color-bg); +} +.crm-nav { + width: min(1180px, calc(100% - 2rem)); + margin: auto; + display: flex; + align-items: center; + justify-content: space-between; + padding: 1.25rem 0; +} +.crm-brand { + display: inline-flex; + align-items: center; + gap: 0.7rem; + font-weight: 800; + letter-spacing: -0.025em; +} +.crm-brand-mark { + display: grid; + width: 2.25rem; + height: 2.25rem; + place-items: center; + border-radius: 0.75rem; + color: white; + background: linear-gradient(135deg, var(--wrn-color-primary), #7c3aed); + box-shadow: 0 10px 24px color-mix(in srgb, var(--wrn-color-primary) 28%, transparent); +} +.crm-nav-links { + display: flex; + align-items: center; + gap: 1.5rem; + color: var(--wrn-color-muted); + font-size: 0.9rem; + font-weight: 600; +} +.crm-button { + display: inline-flex; + min-height: 2.75rem; + align-items: center; + justify-content: center; + padding: 0 1.1rem; + border-radius: 0.75rem; + color: white; + background: var(--wrn-color-primary); + font-weight: 700; + box-shadow: 0 10px 24px color-mix(in srgb, var(--wrn-color-primary) 22%, transparent); +} +.crm-button--ghost { + color: var(--wrn-color-text); + background: var(--wrn-color-surface); + border: 1px solid var(--wrn-color-border); + box-shadow: none; +} +.crm-hero { + width: min(1180px, calc(100% - 2rem)); + margin: auto; + display: grid; + grid-template-columns: 1.05fr 0.95fr; + align-items: center; + gap: 4rem; + padding: clamp(4rem, 9vw, 8rem) 0; +} +.crm-eyebrow { + color: var(--wrn-color-primary); + font-size: 0.75rem; + font-weight: 800; + letter-spacing: 0.13em; + text-transform: uppercase; +} +.crm-hero h1 { + max-width: 12ch; + margin: 1rem 0; + font-size: clamp(3rem, 7vw, 5.5rem); + line-height: 0.98; + letter-spacing: -0.065em; +} +.crm-hero-copy { + max-width: 38rem; + color: var(--wrn-color-muted); + font-size: 1.1rem; + line-height: 1.75; +} +.crm-actions { + display: flex; + flex-wrap: wrap; + gap: 0.8rem; + margin-top: 2rem; +} +.crm-preview { + padding: 1rem; + border: 1px solid var(--wrn-color-border); + border-radius: 1.5rem; + background: color-mix(in srgb, var(--wrn-color-surface) 88%, transparent); + box-shadow: 0 28px 80px rgba(15, 23, 42, 0.15); + transform: rotate(1.5deg); +} +.crm-preview-bar { + display: flex; + gap: 0.4rem; + padding: 0.4rem 0.25rem 1rem; +} +.crm-preview-bar span { + width: 0.6rem; + height: 0.6rem; + border-radius: 50%; + background: var(--wrn-color-border); +} +.crm-preview-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 0.8rem; +} +.crm-preview-card { + min-height: 8rem; + padding: 1rem; + border-radius: 1rem; + background: var(--wrn-color-surface-2); +} +.crm-preview-card strong { + display: block; + margin-top: 0.7rem; + font-size: 1.6rem; +} +.crm-preview-card small { + color: var(--wrn-color-muted); +} + +.crm-shell { + min-height: 100dvh; + display: grid; + grid-template-columns: 16rem 1fr; + background: var(--wrn-color-bg); +} +.crm-sidebar { + position: sticky; + top: 0; + height: 100dvh; + display: flex; + flex-direction: column; + padding: 1.35rem; + border-right: 1px solid var(--wrn-color-border); + background: var(--wrn-color-surface); +} +.crm-menu { + display: grid; + gap: 0.35rem; + margin-top: 2rem; +} +.crm-menu a { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem 0.85rem; + border-radius: 0.75rem; + color: var(--wrn-color-muted); + font-size: 0.9rem; + font-weight: 650; +} +.crm-menu a:hover, +.crm-menu a[aria-current="page"] { + color: var(--wrn-color-primary); + background: color-mix(in srgb, var(--wrn-color-primary) 10%, transparent); +} +.crm-sidebar-footer { + margin-top: auto; +} +.crm-content { + min-width: 0; + padding: clamp(1.25rem, 4vw, 3rem); +} +.crm-topbar { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 2rem; +} +.crm-topbar h1 { + margin: 0; + font-size: clamp(1.75rem, 4vw, 2.4rem); + letter-spacing: -0.04em; +} +.crm-topbar p { + margin: 0.4rem 0 0; + color: var(--wrn-color-muted); +} +.crm-panel { + padding: 1.25rem; + border: 1px solid var(--wrn-color-border); + border-radius: 1rem; + background: var(--wrn-color-surface); + box-shadow: var(--wrn-shadow-1); +} +.crm-stat-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 1rem; + margin-bottom: 1rem; +} +.crm-stat { + padding: 1.25rem; + border: 1px solid var(--wrn-color-border); + border-radius: 1rem; + background: var(--wrn-color-surface); +} +.crm-stat span { + color: var(--wrn-color-muted); + font-size: 0.8rem; + font-weight: 650; +} +.crm-stat strong { + display: block; + margin-top: 0.6rem; + font-size: 2rem; + letter-spacing: -0.04em; +} +.crm-list { + display: grid; + gap: 0.65rem; + padding: 0; + margin: 1rem 0 0; + list-style: none; +} +.crm-list li { + display: grid; + grid-template-columns: minmax(10rem, 1fr) minmax(8rem, 0.7fr) auto; + align-items: center; + gap: 1rem; + padding: 1rem; + border: 1px solid var(--wrn-color-border); + border-radius: 0.8rem; + background: var(--wrn-color-surface-2); +} +.crm-pill { + justify-self: end; + padding: 0.3rem 0.65rem; + border-radius: 999px; + color: var(--wrn-color-primary); + background: color-mix(in srgb, var(--wrn-color-primary) 10%, transparent); + font-size: 0.72rem; + font-weight: 750; + text-transform: capitalize; +} +.crm-empty { + padding: 3rem 1rem; + text-align: center; + color: var(--wrn-color-muted); +} +.crm-empty h2 { + margin-bottom: 0.35rem; + color: var(--wrn-color-text); +} +.crm-status-page { + min-height: 100dvh; + display: grid; + place-items: center; + padding: 1.5rem; + background: radial-gradient( + circle at 50% 0%, + color-mix(in srgb, var(--wrn-color-primary) 12%, transparent), + transparent 45% + ); +} +.crm-status-card { + width: min(34rem, 100%); + padding: clamp(1.5rem, 5vw, 3rem); + border: 1px solid var(--wrn-color-border); + border-radius: 1.25rem; + background: var(--wrn-color-surface); + box-shadow: var(--wrn-shadow-2); +} +.crm-status-card h1 { + margin: 0.9rem 0 0.7rem; + font-size: clamp(1.8rem, 5vw, 2.5rem); + line-height: 1.08; + letter-spacing: -0.045em; +} +.crm-status-card > p { + color: var(--wrn-color-muted); + line-height: 1.65; +} +.crm-status-icon { + display: grid; + width: 3rem; + height: 3rem; + margin-bottom: 1.25rem; + place-items: center; + border-radius: 1rem; + color: #b45309; + background: #fef3c7; + font-size: 1.4rem; + font-weight: 800; +} + +@media (max-width: 820px) { + .crm-nav-links > a:not(.crm-button) { + display: none; + } + .crm-hero { + grid-template-columns: 1fr; + padding-top: 3rem; + } + .crm-preview { + transform: none; + } + .crm-shell { + grid-template-columns: 1fr; + } + .crm-sidebar { + position: static; + width: 100%; + height: auto; + } + .crm-menu { + grid-template-columns: repeat(4, 1fr); + overflow: auto; + } + .crm-sidebar-footer { + margin-top: 1rem; + } + .crm-stat-grid { + grid-template-columns: 1fr; + } +} +@media (max-width: 560px) { + .crm-hero h1 { + font-size: 3rem; + } + .crm-menu { + grid-template-columns: repeat(2, 1fr); + } + .crm-list li { + grid-template-columns: 1fr; + } + .crm-pill { + justify-self: start; + } +} diff --git a/examples/crm-app/app/types/wrnexus.generated.api-checks.ts b/examples/crm-app/app/types/wrnexus.generated.api-checks.ts new file mode 100644 index 00000000..372018ab --- /dev/null +++ b/examples/crm-app/app/types/wrnexus.generated.api-checks.ts @@ -0,0 +1,7 @@ +// AUTO-GENERATED by `wrnexus generate types` - do not edit. +// +// Type-only assertions for sectioned `api` blocks. Kept as a real .ts file (not +// wrnexus.generated.d.ts) because `skipLibCheck` exempts .d.ts contents from being +// checked; this file is compiled and checked normally by the project's own tsc. + +export {}; diff --git a/examples/crm-app/app/types/wrnexus.generated.d.ts b/examples/crm-app/app/types/wrnexus.generated.d.ts new file mode 100644 index 00000000..8c430b7b --- /dev/null +++ b/examples/crm-app/app/types/wrnexus.generated.d.ts @@ -0,0 +1,47 @@ +// AUTO-GENERATED by `wrnexus generate types` - do not edit. +declare namespace WRNexusGenerated { + type ApiContract = T extends import("@wrnexus/core").DefinedEndpoint + ? { input: I; output: O } + : T extends (...args: infer A) => infer R + ? { input: A extends [any, infer I, ...any[]] ? I : unknown; output: Awaited } + : { input: unknown; output: unknown }; + type MiddlewareContext = T extends (ctx: infer C, ...args: any[]) => any ? C : never; + type QueryContract = T extends (db: any, args: infer A, ...rest: any[]) => infer R + ? { args: A; result: Awaited } + : T extends (db: any, ...rest: any[]) => infer R + ? { args: Record; result: Awaited } + : never; + type RealtimeMessage = T extends import("@wrnexus/core").RoomDefinition ? M : unknown; + type QueuePayload = T extends import("@wrnexus/queue").JobDefinition ? I : unknown; + type RouteName = "admin" | "contacts" | "dashboard" | "deals" | "forbidden" | "index" | "login" | "pricing" | "signup"; + type ApiRoute = "/api/contacts" | "/api/dashboard" | "/api/deals"; + type RealtimeRoute = never; + type EnvironmentKey = never; + type TranslationKey = never; + type QueueName = never; + type CacheKey = never; + type Components = Record; + interface ApiContracts { + "/api/dashboard": { GET: ApiContract }; + "/api/contacts": { GET: ApiContract; POST: ApiContract }; + "/api/deals": { GET: ApiContract }; + } + interface MiddlewareContexts { + "authz": MiddlewareContext<(typeof import("../middleware/authz.ts"))["default"]>; + "protected": MiddlewareContext<(typeof import("../middleware/protected.ts"))["default"]>; + } + type DatabaseQueries = Record; + type RealtimeMessages = Record; + type QueuePayloads = Record; + type ApplicationConfig = (typeof import("../../wrnexus.config.ts"))["default"]; + type AssertAssignable = unknown extends Expected + ? true + : [Actual] extends [Expected] + ? [Exclude] extends [never] + ? true + : false + : false; + type __wrn_expect_true = T; + type ApiInput

= ApiContracts[P][M]["input"]; + type ApiOutput

= ApiContracts[P][M]["output"]; +} diff --git a/examples/crm-app/app/types/wrnexus.plugins.generated.d.ts b/examples/crm-app/app/types/wrnexus.plugins.generated.d.ts new file mode 100644 index 00000000..c8d0064d --- /dev/null +++ b/examples/crm-app/app/types/wrnexus.plugins.generated.d.ts @@ -0,0 +1 @@ +// AUTO-GENERATED plugin type aggregation - do not edit. diff --git a/examples/crm-app/package.json b/examples/crm-app/package.json new file mode 100644 index 00000000..2215b7cc --- /dev/null +++ b/examples/crm-app/package.json @@ -0,0 +1,30 @@ +{ + "name": "wrnexus-crm-example", + "version": "0.8.0", + "private": true, + "type": "module", + "scripts": { + "dev": "bun run ../../packages/cli/src/index.ts dev .", + "build": "bun run ../../packages/cli/src/index.ts build .", + "db:migrate": "bun run ../../packages/cli/src/index.ts db migrate .", + "db:seed": "bun run ../../packages/cli/src/index.ts db seed .", + "test": "bun test test", + "typecheck": "tsc --noEmit -p tsconfig.json", + "check": "bun run typecheck && bun run test && bun run build" + }, + "dependencies": { + "@wrnexus/auth": "workspace:*", + "@wrnexus/authz": "workspace:*", + "@wrnexus/core": "workspace:*", + "@wrnexus/db": "workspace:*", + "@wrnexus/styles": "workspace:*" + }, + "devDependencies": { + "@iconify-json/lucide": "^1.2.123", + "@iconify/tailwind4": "^1.2.3", + "@tailwindcss/cli": "^4.3.3", + "@types/bun": "^1.3.14", + "tailwindcss": "^4.3.3", + "typescript": "^6.0.3" + } +} diff --git a/examples/crm-app/test/crm.test.ts b/examples/crm-app/test/crm.test.ts new file mode 100644 index 00000000..5c880ab9 --- /dev/null +++ b/examples/crm-app/test/crm.test.ts @@ -0,0 +1,82 @@ +import { expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { createDb, loadMigrations, migrate } from "@wrnexus/db"; +import { sqlite } from "@wrnexus/db/sqlite"; +import seed from "../app/db/seed.ts"; + +const root = join(import.meta.dir, ".."); +const source = (path: string) => readFileSync(join(root, path), "utf8"); + +test("the CRM migration applies to SQLite, is idempotent, and the seed is repeatable", async () => { + const db = createDb(sqlite()); + const migrations = join(root, "app", "db", "migrations"); + + expect(loadMigrations(migrations).map(({ name }) => name)).toEqual(["001_crm"]); + expect(await migrate(db, migrations)).toEqual(["001_crm"]); + expect(await migrate(db, migrations)).toEqual([]); + await seed(db); + await seed(db); + + expect(await db.one<{ count: number }>("SELECT COUNT(*) count FROM crm_contacts")).toEqual({ + count: 2, + }); + expect(await db.one<{ count: number }>("SELECT COUNT(*) count FROM crm_deals")).toEqual({ + count: 1, + }); + expect( + await db.all( + "SELECT name FROM sqlite_master WHERE type='table' AND name IN ('_wrn_authz_assignment','_wrn_authz_grant') ORDER BY name", + ), + ).toHaveLength(2); + await db.close(); +}); + +test("authentication uses SQL, plugin migrations, signup/login components, and protected routes", () => { + expect(source("app/lib/auth.ts")).toContain("SqlAuthStore"); + expect(source("wrnexus.config.ts")).toContain("migrations: true"); + expect(source("app/pages/login.wrn")).toContain(" { + expect(source("app/middleware/authz.ts")).toContain("dbPermissionStore(getDb())"); + expect(source("app/lib/auth.ts")).toContain('assignRole(user.id, "sales-rep")'); + expect(source("app/api/contacts.ts")).toContain('can(ctx, "contact:write")'); + expect(source("app/api/deals.ts")).toContain('can(ctx, "deal:read")'); + expect(source("app/middleware/protected.ts")).toContain('can(ctx, "admin:access")'); +}); + +test("the app includes public, authentication, and protected CRM pages", () => { + for (const page of [ + "index", + "pricing", + "login", + "signup", + "dashboard", + "contacts", + "deals", + "admin", + "forbidden", + ]) { + expect(source(`app/pages/${page}.wrn`)).toContain("page "); + } +}); + +test("protected CRM data is loaded on the server without browser API bindings", () => { + for (const page of ["contacts", "deals", "dashboard"]) { + const contents = source(`app/pages/${page}.wrn`); + expect(contents).toContain("load server"); + expect(contents).not.toContain(' api="'); + } + expect(source("app/api/dashboard.ts")).toContain("pipeline_value_cents"); + expect(source("app/lib/workspace.ts")).toContain("INSERT OR IGNORE INTO crm_contacts"); + expect(source("app/lib/auth.ts")).toContain("ensureCrmWorkspace(user.id)"); + expect(source("app/middleware/protected.ts")).toContain('new URL("/forbidden", ctx.url)'); +}); diff --git a/examples/crm-app/tsconfig.json b/examples/crm-app/tsconfig.json new file mode 100644 index 00000000..21db131c --- /dev/null +++ b/examples/crm-app/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { "noEmit": true }, + "include": ["app/**/*.ts", "test/**/*.ts", "wrnexus.config.ts"] +} diff --git a/examples/crm-app/wrnexus.config.ts b/examples/crm-app/wrnexus.config.ts new file mode 100644 index 00000000..b78a7305 --- /dev/null +++ b/examples/crm-app/wrnexus.config.ts @@ -0,0 +1,38 @@ +import { join } from "node:path"; +import type { AuthConfig } from "@wrnexus/auth"; +import type { AppConfig } from "@wrnexus/styles"; +import { auth } from "./app/lib/auth.ts"; + +const config = { + seo: { + title: "Northstar CRM", + titleTemplate: "%s | Northstar CRM", + description: "A complete WrNexus SQLite CRM example.", + canonicalBase: "http://localhost:3000", + }, + theme: { palette: "blue", default: "light" }, + db: { driver: "sqlite", url: "file:./crm.sqlite" }, + styles: { + entry: "app/styles/global.css", + async process({ entryPath, appRoot, mode }) { + const args = ["@tailwindcss/cli", "-i", entryPath!]; + if (mode === "production") args.push("--minify"); + return await Bun.$.cwd(appRoot)`bunx ${args}`.text(); + }, + failureMode: "throw", + }, + auth: { + engine: auth, + routes: true, + middleware: true, + components: true, + migrations: true, + baseUrl: "http://localhost:3000", + }, + profiles: { + test: { db: { driver: "sqlite", url: `file:${join(import.meta.dir, ".tmp-test.sqlite")}` } }, + }, + security: { cors: { enabled: false } }, +} satisfies AppConfig & { auth: AuthConfig }; + +export default config; diff --git a/packages/auth/components/SignIn.wrn b/packages/auth/components/SignIn.wrn index 5f0cf351..e9b08d60 100644 --- a/packages/auth/components/SignIn.wrn +++ b/packages/auth/components/SignIn.wrn @@ -25,17 +25,17 @@ component SignIn { class = "" } view { -

-
+ } + + style { + .wrn-auth-sign-in__header { margin-bottom: 1.5rem; } + .wrn-auth-sign-in__header h1 { font-size: 1.75rem; line-height: 1.15; } + .wrn-auth-sign-in__header p { margin-top: 0.35rem; font-size: 0.9375rem; line-height: 1.5; } + .wrn-auth-sign-in__form { display: grid; gap: 1.15rem; } + .wrn-auth-sign-in__form > * { margin: 0 !important; } + .wrn-auth-sign-in__remember { margin-top: -0.25rem !important; } + .wrn-auth-sign-in__form .wrn-next__field-heading label, + .wrn-auth-sign-in__form .wrn-next__password-heading { font-size: 0.875rem; font-weight: 650; } + .wrn-auth-sign-in__form .wrn-next__field-control > input { min-height: 3rem; font-size: 0.875rem; } + .wrn-auth-sign-in__form [data-error]:empty { display: none; } + .wrn-auth-sign-in__form .wrn-action[data-full-width="true"] { margin-top: 0.15rem !important; } + } } diff --git a/packages/auth/components/SignUp.wrn b/packages/auth/components/SignUp.wrn index d4955473..0e63616b 100644 --- a/packages/auth/components/SignUp.wrn +++ b/packages/auth/components/SignUp.wrn @@ -27,7 +27,7 @@ component SignUp {
{/if} @@ -176,15 +174,7 @@ component SignUp { >

- {#if presentation === "popover"}
span { + width: 1.05rem; + height: 1.05rem; + background: currentColor; + mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M2.06 12.35a1 1 0 0 1 0-.7C3.7 7.6 7.64 5 12 5c4.36 0 8.3 2.6 9.94 6.65a1 1 0 0 1 0 .7C20.3 16.4 16.36 19 12 19c-4.36 0-8.3-2.6-9.94-6.65Z'/%3E%3Ccircle cx='12' cy='12' r='3'/%3E%3C/svg%3E") center / contain no-repeat; + } + .wrn-next__strong-password-toggle[aria-pressed="true"]::after { + position: absolute; + width: 1.25rem; + height: 0.12rem; + border-radius: 999px; + background: currentColor; + content: ""; + transform: rotate(45deg); + box-shadow: 0 0 0 1px var(--wrn-color-surface); + } + .wrn-next--strong-password input:focus-visible { border-color: var(--wrn-component-color); box-shadow: 0 0 0 3px color-mix(in srgb, var(--wrn-component-color) 16%, transparent); diff --git a/packages/ui/components/TogglePassword.wrn b/packages/ui/components/TogglePassword.wrn index f7300264..a42a2ee5 100644 --- a/packages/ui/components/TogglePassword.wrn +++ b/packages/ui/components/TogglePassword.wrn @@ -107,6 +107,7 @@ component TogglePassword { {/if}
+ input:focus-visible { border-color: var(--wrn-component-color); box-shadow: 0 0 0 3px color-mix(in srgb, var(--wrn-component-color) 18%, transparent); diff --git a/packages/ui/components/button.wrn b/packages/ui/components/button.wrn index 12d96a56..ff5f2583 100644 --- a/packages/ui/components/button.wrn +++ b/packages/ui/components/button.wrn @@ -36,6 +36,7 @@ label: string = "Button" view { {#if as === "a" || (as === "" && href)} .wrn-btn { + width: 100%; + } + .wrn-btn--pill { border-radius: 999px; } diff --git a/packages/ui/package.json b/packages/ui/package.json index 775ee675..31060c92 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/ui", - "version": "0.8.20", + "version": "0.8.21", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/ui/test/ui.test.ts b/packages/ui/test/ui.test.ts index 1eb9a857..b496cf63 100644 --- a/packages/ui/test/ui.test.ts +++ b/packages/ui/test/ui.test.ts @@ -263,6 +263,18 @@ test("auth form uses package schemas, disables native validation, and renders a expect(html).toContain('fullWidth="true"'); }); +test("password controls provide matching lock and visibility affordances", () => { + const toggle = readFileSync(uiComponentPath("TogglePassword"), "utf8"); + const strong = readFileSync(uiComponentPath("StrongPassword"), "utf8"); + const button = readFileSync(uiComponentPath("Button"), "utf8"); + + expect(toggle).toContain("wrn-next__password-leading-icon"); + expect(strong).toContain("wrn-next__strong-password-leading-icon"); + expect(strong).toContain("wrn-next__strong-password-toggle"); + expect(strong).toContain("type=\"{revealed ? 'text' : 'password'}\""); + expect(button).toContain("data-full-width=\"{fullWidth ? 'true' : 'false'}\""); +}); + test("footer supports typed entries, responsive columns, pre/post slots, and events", () => { const source = readFileSync(uiComponentPath("Footer"), "utf8"); expect(source).toMatch(//);