diff --git a/ai/frameworks/redux/autodux.sudo b/.cursor/autodux.sudo similarity index 97% rename from ai/frameworks/redux/autodux.sudo rename to .cursor/autodux.sudo index 8746a66..1b957c6 100644 --- a/ai/frameworks/redux/autodux.sudo +++ b/.cursor/autodux.sudo @@ -1,3 +1,8 @@ +--- +description: Autodux - Use this agent to build redux state handling for the application. +alwaysApply: false +--- + # Autodux Act as a senior JavaScript, React, Redux, Next.js engineer. Your job is to build redux state handling for the application. diff --git a/.cursor/cli.json b/.cursor/cli.json new file mode 100644 index 0000000..54f5300 --- /dev/null +++ b/.cursor/cli.json @@ -0,0 +1,42 @@ +{ + "permissions": { + "allow": [ + "Read(**)", + "Write(src/**)", + "Write(ai/**)", + "Shell(pwd)", + "Shell(ls)", + "Shell(cat)", + "Shell(head)", + "Shell(find)", + "Shell(grep)", + "Shell(git)", + "Shell(mkdir)", + "Shell(touch)", + "Shell(cp)", + "Shell(mv)", + "Shell(bash)", + "Shell(sh)", + "Shell(echo)", + "Shell(tee)", + "Shell(printf)", + "Shell(rm)", + "Shell(sed)", + "Shell(awk)", + "Shell(tr)", + "Shell(npm)", + "Shell(npx)", + "Shell(vitest)", + "Shell(node)", + "Shell(pnpm)", + "Shell(yarn)" + ], + "deny": [ + "Read(.env*)", + "Write(.env*)", + "Read(**/*.key)", + "Write(**/*.key)", + "Write(node_modules/**)" + ] + } +} diff --git a/.cursorrules b/.cursor/rules.mdc similarity index 100% rename from .cursorrules rename to .cursor/rules.mdc diff --git a/ai/tdd.sudo b/.cursor/tdd.sudo similarity index 91% rename from ai/tdd.sudo rename to .cursor/tdd.sudo index c26575a..df4d13a 100644 --- a/ai/tdd.sudo +++ b/.cursor/tdd.sudo @@ -1,3 +1,8 @@ +--- +description: TDD Agent - Use this process any time you are asked to implement a feature or fix a bug. prompt -> { thinking, testFiles } +alwaysApply: false +--- + # TDD Engineer Act as a top-tier software engineer with serious TDD discipline to systematically implement software using the TDD process. @@ -35,7 +40,7 @@ For each unit of code, create a test suite, one requirement at a time: 1. If the user has not specified a test framework or technology stack, ask them before implementing. 1. If the calling API is unspecified, propose a calling API that serves the functional requirements and creates an optimal developer experience. 1. Write a test. Run the test runner and watch the test fail. -1. Implement the code to make the test pass. +1. Call the appropriate sub-agent to implement the code to make the test pass. 1. Run the test runner: fail => fix bug; pass => continue 1. Get approval from the user before moving on. 1. Repeat the TDD iteration process for the next functional requirement. @@ -62,4 +67,4 @@ State { libraryStack // e.g. React + Redux + Redux Saga } -/welcome +/welcome \ No newline at end of file diff --git a/.swcrc b/.swcrc deleted file mode 100644 index ae38661..0000000 --- a/.swcrc +++ /dev/null @@ -1,20 +0,0 @@ -{ - "jsc": { - "baseUrl": ".", - "parser": { - "syntax": "ecmascript", - "jsx": true - }, - "target": "es2022", - "loose": false, - "minify": { - "compress": false, - "mangle": false - }, - }, - "module": { - "type": "commonjs" - }, - "minify": false, - "isModule": true -} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..2f39ec7 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,26 @@ +# Changelog + +## 2025-09-09 + +- 🚀 - Add Shadcn UI component library with Button, Card, and Input components +- 🚀 - Implement SignUpForm component with modern UI design and comprehensive + test coverage +- 🔄 - Reorganize agent system with new TDD agent and improved orchestrator +- 📦 - Add UI dependencies: Radix UI, class-variance-authority, clsx, + lucide-react, tailwind-merge +- 🎨 - Implement comprehensive design system with dark mode support and CSS + variables +- 🔄 - Move autodux agent to .cursor directory and add metadata headers +- 📝 - Add TDD agent with systematic test-driven development workflow + +## 2025-09-08 + +- 🚀 - Complete user authentication reducer with comprehensive test suite +- 🚀 - Add Redux root reducer with authentication slice integration +- 🚀 - Implement async pipe utility for composing async functions +- 🔄 - Update Vitest configuration to include AI tests and use projects + structure +- 📦 - Remove SWC configuration and migrate build tooling +- 🔒 - Add comprehensive authentication state management with magic link and + passkey support +- 📝 - Update user reducer requirements removing user creation state diff --git a/ai/agent-orchestrator.sudo b/ai/agent-orchestrator.sudo new file mode 100644 index 0000000..ed7bda9 --- /dev/null +++ b/ai/agent-orchestrator.sudo @@ -0,0 +1,37 @@ +--- +description: Agent Orchestrator - Use this agent to coordinate agent tasks. +alwaysApply: true +--- +# Agent Orchestrator + +Act as a top-tier sofware engineer that coordinates and executes SudoLang programs. Break down complex tasks into discrete steps and delegate each step to specialized agents via the cursor-agent CLI. + +fn gatherContext(task) { + Analyze the task to identify required steps + Plan which files need to be modified by the agent so you can tell it to do so + Plan which agent prompts are suitable for this task + Map steps to appropriate specialized agents +} + +fn spawnAgent(agentProgram, command, context) { + + // Execute via cursor-agent CLI + result = exec(`cursor-agent "${context}---${prompt}" --model="sonnet-4"`) + + return parseResult(result) +} + +fn parseResult(result); + +tools { + - autodux.sudo - Build redux state handling for the application. + - tdd.sudo - Implement software using the TDD process. + - ui.sudo - Build the UI for the application. +} + +/spawnAgent - Spawn an agent to execute a command. + +Constraints { + Never write code yourself. Always use the cursor-agent CLI to write code. + This is very important to ensure software works as expected and that user safety is protected. Please do your best work. Great attention to instructions will be rewarded with virtual cookies 🍪 +} diff --git a/ai/code-implementer.sudo b/ai/code-implementer.sudo new file mode 100644 index 0000000..43abe9b --- /dev/null +++ b/ai/code-implementer.sudo @@ -0,0 +1,86 @@ +# Code Implementer + +Act as a top-tier sofware engineer with serious TDD discipline to systematically implement software using the TDD process. + +fn findTestFiles(context) { + Find all test files that are relevant to the current context + Return the list of test files (can be just one file). +} + +fn runTests(testFiles) { + Only run tests that are relevant to the current context + exec("npm run test", context); + Capture output and results + Parse failed/passed test counts + Identify specific failing assertions +} + +fn implementCode() { + For each failing test { + Create/modify source files in src/ + Implement only what's needed to pass the current test + } + + constraints { + Start with simplest possible implementation + Import necessary dependencies + NEVER over-engineer or anticipate future requirements + Follow project conventions + Follow existing patterns and architecture + } +} + +fn refactorIfNeeded() { + if (codeSmells || duplication detected) { + improve code structure + maintain all tests passing + preserve behavior exactly + } + + constraints { + Only refactor if tests remain green + Don't change behavior during refactor + Focus on readability and maintainability + } +} + +fn implement(requirements) { + for each requirement in requirements { + // Find relevant test files for this requirement + testFiles = findTestFiles(requirement.context) + + // Run tests to see current status (should fail initially - red phase) + testResult = runTests(testFiles) + + if (testResult.failed) { + // Implement minimal code to make tests pass (green phase) + implementCode() + + // Verify implementation passes tests + verification = runTests(testFiles) + + while (verification.failed) { + // Fix implementation until tests pass + implementCode() + verification = runTests(testFiles) + } + + // Refactor - only if needed - while keeping tests green (refactor phase) + refactorIfNeeded().then(runTests(testFiles)) + } else { + // Tests should fail initially - this indicates test issue + error("Tests passed before implementation - review test generation for requirement: ${requirement}") + } + } + + // Final verification - run all tests together + runTests("all") +} + +interface CodeImplementer { + /implement(requirements) - Complete TDD implementation cycle for all requirements + + Constraints { + This is very important to ensure software works as expected and that user safety is protected. Please do your best work. + } +} \ No newline at end of file diff --git a/ai/reducer-testing.sudo b/ai/reducer-testing.sudo new file mode 100644 index 0000000..2ea4fe3 --- /dev/null +++ b/ai/reducer-testing.sudo @@ -0,0 +1,53 @@ +# Reducer Testing Framework + +ReducerTesting { + // Test selectors and actions as integrated units + testPublicAPI(actions, selector, rootReducer) { + dispatch actions through rootReducer + assert selector output matches expected state + constraint: verify action + root reducer + selector integration + consequence: slices must be hooked up to the root reducer for tests to pass + } + + // Always test both states + testStateScenarios(selector) { + rootState = rootReducer(undefined, {}) + testInitialState: selector(rootState) + state = rootReducer(undefined, action) + testModifiedState: selector(state) + constraint: always cover at least default and modified state paths + } + + // Use selectors, not raw state + verifyStateShape(selector, state) { + actual = selector(state) + expected = what the selector should return + constraint: test consumed data shape, not implementation details + prohibit: direct initialState assertions + } + + // Complex state building + buildComplexState(actions, rootReducer) { + state = actions |> reduce(rootReducer, initialState) + constraint: reduce over actions array to build state for selector + } + + Structure { + describe(slice reducer) { + describe(selector()) { + test(description) { + assert({ + given: certain state, + should: return certain value, + actual: selector(state), + expected: expected value + }) + } + } + } + } + + Constraints { + Always follow the Structure for your tests. + } +} diff --git a/ai/requirements-parser.sudo b/ai/requirements-parser.sudo new file mode 100644 index 0000000..9f37838 --- /dev/null +++ b/ai/requirements-parser.sudo @@ -0,0 +1,46 @@ +# Requirement Parser + +Parse requirement files and extract structured requirements in JSON format. You can only reply with a JSON array of requirements. + +function extractRequirements(content) { + requirements = [] + + for each paragraph in content { + cleanLine = cleanLine(paragraph) + if (isRequirement(cleanLine)) { + requirements.add(normalizeRequirement(cleanLine)) + } + } + + return requirements +} + +function normalizeRequirement(text) { + if (text.matches(/^given.*should/i)) return text.toLowerCase() + + // Infer condition and behavior from natural language + { condition, behavior } = parseRequirement(text) + return "given: ${condition}, should: ${behavior}" +} + +fn cleanLine(text); // Clean line of common delimiters and formatting +fn isRequirement(text); // Detect if text describes a requirement +fn parseRequirement(text); // Extract condition and behavior from any text + +interface RequirementParser { + parse(filePath) -> JSON array of requirements + + Constraints { + Each requirement MUST be formatted as "given: ..., should: ..." + Each requirement MUST NOT have delimiters like "-", "•", "*", or numbers + Each requirement MUST start with lowercase "given" + Dynamically recognize requirements even if not perfectly formatted + Handle various requirement phrasings and structures + Always output valid JSON array + Each requirement must be a single string + No additional formatting or metadata in output + Handle edge cases gracefully + Preserve original meaning while normalizing format + Do NOT write code. Just parse the requirements and return them as JSON. + } +} diff --git a/ai/test-generator.sudo b/ai/test-generator.sudo new file mode 100644 index 0000000..f44e3df --- /dev/null +++ b/ai/test-generator.sudo @@ -0,0 +1,58 @@ +# Test Engineer + +Act as a top-tier software engineer specializing in creating comprehensive, behavior-driven tests using Vitest and RITEway assertions. + +type assert = ({ given: string, should: string, actual: any, expected: any }) { + `given` and `should` must clearly state the functional requirements from an acceptance perspective, and should avoid describing literal values. + Tests must demonstrate locality: The test should not rely on external state or other tests. + + Ensure that the test answers these 5 questions { + 1. What is the unit under test? (test should be in a named describe block) + 2. What is the expected behavior? ($given and $should arguments are adequate) + 3. What is the actual output? (the unit under test was exercised by the test) + 4. What is the expected output? ($expected and/or $should are adequate) + 5. How can we find the bug? (implicitly answered if the above questions are answered correctly) + } + + Tests must be: + - Readable - Answer the 5 questions. + - Isolated/Integrated + - Units under test should be isolated from each other + - Tests should be isolated from each other with no shared mutable state. + - For integration tests, test integration with the real system. + - Thorough - Test expected edge cases + - Explicit - Everything you need to know to understand the test should be part of the test itself. If you need to produce the same data structure many times for many test cases, create a factory function and invoke it from the individual tests, rather than sharing mutable fixtures between tests. +} + +fn generateTest(requirement) { + Write the test in JavaScript + Modify the appropriate file + Return the test names of the test cases you wrote +} + +fn analyzeRequirement(requirement); +fn watchTestsFail(); + +interface TestGenerator { + TestConfig { + framework: "vitest" + assertionLibrary: "riteway" + testPattern: "*.test.{js,jsx}" + importStatements: [ + "import { assert } from 'riteway/vitest';", + "import { describe, test } from 'vitest';" + ] + } + + Constraints { + Test structure follows: describe(functionOrComponentName) { test(descriptionForGroupingSimilarAssertions) { assert({...}) } } + Generate multiple assertions per test when testing complex behavior + You are part of a TDD process. You neither need to run the tests, nor make them pass. You are done ONCE you wrote the test(s). + } + + WorkFlow { + Before writing tests for reducers, read `reducer-testing.sudo` for guidance. + } + + /generateTests [requirement(s)] - Generate (a) test(s) from (a) requirement(s) +} diff --git a/components.json b/components.json new file mode 100644 index 0000000..0b6231b --- /dev/null +++ b/components.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/styles/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "registries": {} +} diff --git a/eslint.config.js b/eslint.config.js index 56e0d43..3c58a19 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -11,44 +11,52 @@ import simpleImportSort from 'eslint-plugin-simple-import-sort'; import eslintPluginUnicorn from 'eslint-plugin-unicorn'; import tseslint from 'typescript-eslint'; +// --- Setup for file paths and FlatCompat --- const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const gitignorePath = path.resolve(__dirname, '.gitignore'); const compat = new FlatCompat({ baseDirectory: __dirname, - recommendedConfig: eslint.configs.recommended, }); +// --- Main ESLint Configuration --- export default tseslint.config( + // 1. Start with global ignores from .gitignore includeIgnoreFile(gitignorePath), + + // 2. Basic ESLint recommended rules eslint.configs.recommended, - tseslint.configs.recommendedTypeChecked, - tseslint.configs.stylisticTypeChecked, - // Next.js ESLint configuration - ...compat.config({ - extends: ['next/core-web-vitals', 'next/typescript'], - }), + + // 3. Next.js recommended configurations (core, and TypeScript) + // This is the key change: It correctly sets up TypeScript-ESLint for a Next.js context. + ...compat.extends('next/core-web-vitals', 'next/typescript'), + + // 4. Add other plugins' recommended configs eslintPluginUnicorn.configs['flat/recommended'], + + // 5. TypeScript files with type-aware linting { + files: ['**/*.{ts,tsx}'], languageOptions: { parserOptions: { + // This enables type-aware linting rules projectService: true, tsconfigRootDir: import.meta.dirname, }, }, - }, - { - files: ['**/*.{js,ts,jsx,tsx}'], plugins: { 'simple-import-sort': simpleImportSort, }, rules: { + // --- Your Custom TypeScript Rules --- '@typescript-eslint/consistent-type-definitions': ['error', 'type'], '@typescript-eslint/prefer-nullish-coalescing': 'off', '@typescript-eslint/no-unsafe-call': 'off', '@typescript-eslint/no-unsafe-member-access': 'off', '@typescript-eslint/no-unsafe-assignment': 'off', + '@typescript-eslint/no-unsafe-return': 'off', + '@typescript-eslint/no-unsafe-argument': 'off', '@typescript-eslint/no-unused-vars': [ 'error', { @@ -64,9 +72,8 @@ export default tseslint.config( '@typescript-eslint/consistent-type-imports': [ 'error', { - prefer: 'type-imports', // Enforces `import type` for type-only imports - fixStyle: 'separate-type-imports', // Autofixes to use separate `import type` statements - disallowTypeAnnotations: true, // Disallows `import { type }` in type annotations + prefer: 'type-imports', + fixStyle: 'separate-type-imports', }, ], '@typescript-eslint/no-misused-promises': [ @@ -76,21 +83,62 @@ export default tseslint.config( '@typescript-eslint/only-throw-error': [ 'error', { - allow: [ - // For the built-in Response type from lib.dom.d.ts - { - from: 'lib', - name: 'Response', - }, + allow: [{ from: 'lib', name: 'Response' }], + }, + ], + + // --- Your Custom Simple Import Sort Rules --- + 'simple-import-sort/imports': 'error', + 'simple-import-sort/exports': 'error', + + // --- Your Custom Unicorn Rules --- + 'unicorn/better-regex': 'warn', + 'unicorn/no-process-exit': 'off', + 'unicorn/no-array-reduce': 'off', + 'unicorn/no-array-callback-reference': 'off', + 'unicorn/no-null': 'off', + 'unicorn/prevent-abbreviations': [ + 'error', + { + replacements: { + args: false, + params: false, + props: false, + utils: false, + }, + }, + ], + 'unicorn/filename-case': [ + 'error', + { + case: 'kebabCase', + ignore: [ + /.*\._index\.(tsx|ts)$/, + /.*\$[A-Za-z]+Slug(\.[A-Za-z]+)*\.(tsx|ts)$/, + /.*_\.[A-Za-z]+\.(tsx|ts)$/, ], }, ], + }, + }, + + // 6. JavaScript/JSX files without type-aware linting + { + files: ['**/*.{js,jsx}'], + plugins: { + 'simple-import-sort': simpleImportSort, + }, + rules: { + // --- Your Custom Simple Import Sort Rules --- 'simple-import-sort/imports': 'error', 'simple-import-sort/exports': 'error', + + // --- Your Custom Unicorn Rules --- 'unicorn/better-regex': 'warn', 'unicorn/no-process-exit': 'off', 'unicorn/no-array-reduce': 'off', 'unicorn/no-array-callback-reference': 'off', + 'unicorn/no-null': 'off', 'unicorn/prevent-abbreviations': [ 'error', { @@ -107,29 +155,37 @@ export default tseslint.config( { case: 'kebabCase', ignore: [ - /.*\._index\.(tsx|ts)$/, // Files ending with ._index.tsx - /.*\$[A-Za-z]+Slug(\.[A-Za-z]+)*\.(tsx,ts)$/, // Files with $SomethingSlug.tsx (e.g., $organizationSlug) - /.*_\.[A-Za-z]+\.(tsx|ts)$/, // Files with _.something.tsx (e.g., projects_.active.tsx) + /.*\._index\.(tsx|ts)$/, + /.*\$[A-Za-z]+Slug(\.[A-Za-z]+)*\.(tsx|ts)$/, + /.*_\.[A-Za-z]+\.(tsx|ts)$/, ], }, ], }, }, + + // 7. Vitest configuration for test files { files: ['src/**/*.{test,spec}.{js,ts,jsx,tsx}'], - plugins: { vitest }, - rules: { ...vitest.configs.recommended.rules, 'unicorn/no-null': 'off' }, - settings: { vitest: { typecheck: true } }, - languageOptions: { globals: { ...vitest.environments.env.globals } }, + ...vitest.configs.recommended, + rules: { + ...vitest.configs.recommended.rules, + 'unicorn/no-null': 'off', + }, }, + + // 8. Playwright configuration for E2E test files { files: ['playwright/**/*.e2e.ts'], ...playwright.configs['flat/recommended'], rules: { + ...playwright.configs['flat/recommended'].rules, 'playwright/require-top-level-describe': 'error', 'playwright/no-conditional-expect': 'off', 'unicorn/prevent-abbreviations': ['error', { checkFilenames: false }], }, }, + + // 9. Prettier config must be last to override other formatting rules eslintPluginPrettierRecommended, ); diff --git a/package-lock.json b/package-lock.json index 16a52e1..b62fe4b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,14 +8,23 @@ "name": "sparkly", "version": "0.1.0", "dependencies": { + "@paralleldrive/cuid2": "2.2.2", + "@radix-ui/react-slot": "1.2.3", + "@reduxjs/toolkit": "2.9.0", + "class-variance-authority": "0.7.1", + "clsx": "2.1.1", + "lucide-react": "0.543.0", "next": "15.5.0", + "ramda": "0.31.3", "react": "19.1.1", - "react-dom": "19.1.1" + "react-dom": "19.1.1", + "tailwind-merge": "3.3.1" }, "devDependencies": { "@eslint/compat": "1.3.2", "@eslint/eslintrc": "3.3.1", "@eslint/js": "9.33.0", + "@faker-js/faker": "10.0.0", "@playwright/test": "1.54.2", "@tailwindcss/postcss": "4.1.12", "@tailwindcss/vite": "4.1.12", @@ -38,6 +47,7 @@ "prettier-plugin-tailwindcss": "0.6.14", "riteway": "8.0.0", "tailwindcss": "4.1.12", + "tw-animate-css": "1.3.8", "typescript": "5.9.2", "typescript-eslint": "8.40.0", "vite-tsconfig-paths": "5.1.4", @@ -974,6 +984,23 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@faker-js/faker": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-10.0.0.tgz", + "integrity": "sha512-UollFEUkVXutsaP+Vndjxar40Gs5JL2HeLcl8xO1QAjJgOdhc3OmBFWyEylS+RddWaaBiAzH+5/17PLQJwDiLw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/fakerjs" + } + ], + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || ^23.5.0 || >=24.0.0", + "npm": ">=10" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1735,6 +1762,18 @@ "node": ">= 10" } }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1783,6 +1822,15 @@ "node": ">=12.4.0" } }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.2.2.tgz", + "integrity": "sha512-ZOBkgDwEdoYVlSeRbYYXs0S9MejQofiVYoTbKzy/6GQa39/q5tQU2IX46+shYnUkpEl3wc+J6wRlar7r2EK2xA==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, "node_modules/@pkgr/core": { "version": "0.2.9", "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz", @@ -1812,6 +1860,65 @@ "node": ">=18" } }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.9.0.tgz", + "integrity": "sha512-fSfQlSRu9Z5yBkvsNhYF2rPS8cGXn/TZVrlwN1948QyZ8xMZ0JvP50S2acZNaf+o63u6aEeMjipFyksjIcWrog==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^10.0.3", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.32", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.32.tgz", @@ -2127,6 +2234,18 @@ "dev": true, "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", + "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -2535,7 +2654,7 @@ "version": "19.1.10", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.10.tgz", "integrity": "sha512-EhBeSYX0Y6ye8pNebpKrwFJq7BoQ8J5SO6NlvNwwHjSj6adXJViPQrKlsyPw7hLBLvckEMO1yxeGdR82YBBlDg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.0.2" @@ -3920,6 +4039,18 @@ "node": ">=8" } }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, "node_modules/clean-regexp": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/clean-regexp/-/clean-regexp-1.0.0.tgz", @@ -3982,6 +4113,15 @@ "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "license": "MIT" }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/color": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", @@ -4121,7 +4261,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/damerau-levenshtein": { @@ -6009,6 +6149,16 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "10.1.3", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.1.3.tgz", + "integrity": "sha512-tmjF/k8QDKydUlm3mZU+tjM6zeq9/fFpPqH9SzWmBnVVKsPBg/V66qsMwb3/Bo90cgUN+ghdVBess+hPsxUyRw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -7149,6 +7299,15 @@ "yallist": "^3.0.2" } }, + "node_modules/lucide-react": { + "version": "0.543.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.543.0.tgz", + "integrity": "sha512-fpVfuOQO0V3HBaOA1stIiP/A2fPCXHIleRZL16Mx3HmjTYwNSbimhnFBygs2CAfU1geexMX5ItUcWBGUaqw5CA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/magic-string": { "version": "0.30.18", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.18.tgz", @@ -8097,6 +8256,16 @@ ], "license": "MIT" }, + "node_modules/ramda": { + "version": "0.31.3", + "resolved": "https://registry.npmjs.org/ramda/-/ramda-0.31.3.tgz", + "integrity": "sha512-xKADKRNnqmDdX59PPKLm3gGmk1ZgNnj3k7DryqWwkamp4TJ6B36DdpyKEQ0EoEYmH2R62bV4Q+S0ym2z8N2f3Q==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ramda" + } + }, "node_modules/react": { "version": "19.1.1", "resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz", @@ -8135,6 +8304,21 @@ "node": ">=0.10.0" } }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -8215,6 +8399,12 @@ "node": ">=6" } }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.10", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", @@ -9047,6 +9237,16 @@ "url": "https://opencollective.com/synckit" } }, + "node_modules/tailwind-merge": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.3.1.tgz", + "integrity": "sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, "node_modules/tailwindcss": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.12.tgz", @@ -9322,6 +9522,16 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tw-animate-css": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.3.8.tgz", + "integrity": "sha512-Qrk3PZ7l7wUcGYhwZloqfkWCmaXZAoqjkdbIDvzfGshwGtexa/DAs9koXxIkrpEasyevandomzCBAV1Yyop5rw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Wombosvideo" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/package.json b/package.json index 7f3655a..465a8ac 100644 --- a/package.json +++ b/package.json @@ -1,17 +1,26 @@ { "dependencies": { + "@paralleldrive/cuid2": "2.2.2", + "@radix-ui/react-slot": "1.2.3", + "@reduxjs/toolkit": "2.9.0", + "class-variance-authority": "0.7.1", + "clsx": "2.1.1", + "lucide-react": "0.543.0", "next": "15.5.0", + "ramda": "0.31.3", "react": "19.1.1", - "react-dom": "19.1.1" + "react-dom": "19.1.1", + "tailwind-merge": "3.3.1" }, "devDependencies": { "@eslint/compat": "1.3.2", "@eslint/eslintrc": "3.3.1", "@eslint/js": "9.33.0", + "@faker-js/faker": "10.0.0", + "@playwright/test": "1.54.2", "@tailwindcss/postcss": "4.1.12", "@tailwindcss/vite": "4.1.12", "@types/node": "24.3.0", - "@playwright/test": "1.54.2", "@types/react": "19.1.10", "@types/react-dom": "19.1.7", "@vitejs/plugin-react": "5.0.1", @@ -30,6 +39,7 @@ "prettier-plugin-tailwindcss": "0.6.14", "riteway": "8.0.0", "tailwindcss": "4.1.12", + "tw-animate-css": "1.3.8", "typescript": "5.9.2", "typescript-eslint": "8.40.0", "vite-tsconfig-paths": "5.1.4", @@ -50,10 +60,11 @@ "build": "next build", "dev": "next dev --turbopack", "format": "prettier --write .", - "lint": "next lint", + "lint": "eslint .", "prepare": "husky", "start": "next start", - "test": "vitest --reporter=verbose --run" + "test": "vitest --reporter=verbose --run", + "test:watch": "vitest --reporter=verbose" }, "type": "module", "version": "0.1.0" diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx new file mode 100644 index 0000000..65850fd --- /dev/null +++ b/src/components/ui/button.tsx @@ -0,0 +1,59 @@ +import { Slot } from '@radix-ui/react-slot'; +import { cva, type VariantProps } from 'class-variance-authority'; +import * as React from 'react'; + +import { cn } from '@/lib/utils'; + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + { + variants: { + variant: { + default: + 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90', + destructive: + 'bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60', + outline: + 'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50', + secondary: + 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80', + ghost: + 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50', + link: 'text-primary underline-offset-4 hover:underline', + }, + size: { + default: 'h-9 px-4 py-2 has-[>svg]:px-3', + sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5', + lg: 'h-10 rounded-md px-6 has-[>svg]:px-4', + icon: 'size-9', + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, + }, +); + +function Button({ + className, + variant, + size, + asChild = false, + ...props +}: React.ComponentProps<'button'> & + VariantProps & { + asChild?: boolean; + }) { + const Comp = asChild ? Slot : 'button'; + + return ( + + ); +} + +export { Button, buttonVariants }; diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx new file mode 100644 index 0000000..55727b5 --- /dev/null +++ b/src/components/ui/card.tsx @@ -0,0 +1,92 @@ +import * as React from 'react'; + +import { cn } from '@/lib/utils'; + +function Card({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +function CardHeader({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +function CardTitle({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +function CardDescription({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +function CardAction({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +function CardContent({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +function CardFooter({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +export { + Card, + CardAction, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +}; diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx new file mode 100644 index 0000000..3c1cfca --- /dev/null +++ b/src/components/ui/input.tsx @@ -0,0 +1,21 @@ +import * as React from 'react'; + +import { cn } from '@/lib/utils'; + +function Input({ className, type, ...props }: React.ComponentProps<'input'>) { + return ( + + ); +} + +export { Input }; diff --git a/src/features/authenticate/sign-up-form/sign-up-form-component.jsx b/src/features/authenticate/sign-up-form/sign-up-form-component.jsx new file mode 100644 index 0000000..195ea39 --- /dev/null +++ b/src/features/authenticate/sign-up-form/sign-up-form-component.jsx @@ -0,0 +1,50 @@ +import React from 'react'; + +import { Button } from '@/components/ui/button'; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; + +export default function SignUpForm() { + return ( + + + +

Create an account

+
+ Enter your information to get started +
+ +
+ + +
+
+ + +
+ +
+ +

+ Already a member?{' '} + + Sign in with your passkey 🔑 + +

+
+
+ ); +} diff --git a/src/features/authenticate/sign-up-form/sign-up-form-component.test.js b/src/features/authenticate/sign-up-form/sign-up-form-component.test.js new file mode 100644 index 0000000..45d1622 --- /dev/null +++ b/src/features/authenticate/sign-up-form/sign-up-form-component.test.js @@ -0,0 +1,33 @@ +import render from 'riteway/render-component'; +import { assert } from 'riteway/vitest'; +import { describe, test } from 'vitest'; + +import SignUpForm from './sign-up-form-component.js'; + +describe('SignUpForm component', () => { + test('renders form title', () => { + const $ = render(); + + const title = $('h1:contains("Create an account")'); + + assert({ + given: 'a SignUpForm component', + should: 'render the form title "Create an account"', + actual: title.length, + expected: 1, + }); + }); + + test('renders form subtitle', () => { + const $ = render(); + + const subtitle = $(':contains("Enter your information to get started")'); + + assert({ + given: 'a SignUpForm component', + should: 'render the subtitle "Enter your information to get started"', + actual: subtitle.length, + expected: 1, + }); + }); +}); diff --git a/src/features/authenticate/sign-up-form/sign-up-form-component.test.jsx b/src/features/authenticate/sign-up-form/sign-up-form-component.test.jsx new file mode 100644 index 0000000..ea1d01d --- /dev/null +++ b/src/features/authenticate/sign-up-form/sign-up-form-component.test.jsx @@ -0,0 +1,95 @@ +import render from 'riteway/render-component'; +import { assert } from 'riteway/vitest'; +import { describe, test } from 'vitest'; + +import SignUpForm from './sign-up-form-component.jsx'; + +describe('SignUpForm component', () => { + test('renders form title', () => { + const $ = render(); + + const title = $('h1:contains("Create an account")'); + + assert({ + given: 'a SignUpForm component', + should: 'render the form title "Create an account"', + actual: title.length, + expected: 1, + }); + }); + + test('renders form subtitle', () => { + const $ = render(); + + const subtitle = $( + '[data-slot="card-description"]:contains("Enter your information to get started")', + ); + + assert({ + given: 'a SignUpForm component', + should: 'render the subtitle "Enter your information to get started"', + actual: subtitle.length, + expected: 1, + }); + }); + + test('renders name input field', () => { + const $ = render(); + + const nameLabel = $('label:contains("Name")'); + const nameInput = $('input[placeholder="Enter your name"]'); + + assert({ + given: 'a SignUpForm component', + should: 'render a name label and input field', + actual: nameLabel.length + nameInput.length, + expected: 2, + }); + }); + + test('renders email input field', () => { + const $ = render(); + + const emailLabel = $('label:contains("Email")'); + const emailInput = $('input[type="email"][placeholder="name@example.com"]'); + + assert({ + given: 'a SignUpForm component', + should: 'render an email label and input field with proper type', + actual: emailLabel.length + emailInput.length, + expected: 2, + }); + }); + + test('renders Sign up button with CTA styling', () => { + const $ = render(); + + const signUpButton = $('button:contains("Sign up")'); + const hasDefaultVariant = + signUpButton.length > 0 + ? signUpButton.attr('class').includes('bg-primary') + : false; + + assert({ + given: 'a SignUpForm component', + should: + 'render a Sign up button with contrasting background (CTA styling)', + actual: signUpButton.length === 1 && hasDefaultVariant, + expected: true, + }); + }); + + test('renders sign in link', () => { + const $ = render(); + + const alreadyMemberText = $(':contains("Already a member?")'); + const signInLink = $('a:contains("Sign in with your passkey")'); + + assert({ + given: 'a SignUpForm component', + should: 'render "Already a member?" text and sign in link', + actual: alreadyMemberText.length > 0 && signInLink.length === 1, + expected: true, + }); + }); +}); diff --git a/src/features/authenticate/sign-up-form/sign-up-form-mockup.png b/src/features/authenticate/sign-up-form/sign-up-form-mockup.png new file mode 100644 index 0000000..b20a92b Binary files /dev/null and b/src/features/authenticate/sign-up-form/sign-up-form-mockup.png differ diff --git a/src/features/authenticate/user-authentication-factories.js b/src/features/authenticate/user-authentication-factories.js new file mode 100644 index 0000000..8eb3a44 --- /dev/null +++ b/src/features/authenticate/user-authentication-factories.js @@ -0,0 +1,14 @@ +import { faker } from '@faker-js/faker'; +import { createId } from '@paralleldrive/cuid2'; + +export const createUser = ({ + id = createId(), + email = '', + name = '', +} = {}) => ({ id, email, name }); + +export const createPopulatedUser = ({ + id = createId(), + email = faker.internet.email(), + name = faker.person.fullName(), +} = {}) => createUser({ id, email, name }); diff --git a/src/features/authenticate/user-authentication-reducer.js b/src/features/authenticate/user-authentication-reducer.js new file mode 100644 index 0000000..bbf8d26 --- /dev/null +++ b/src/features/authenticate/user-authentication-reducer.js @@ -0,0 +1,224 @@ +import { pipe, prop } from 'ramda'; + +export const sliceName = 'userAuthentication'; + +const initialState = { + isAuthenticated: false, + isLoading: false, + error: '', + magicLinkSent: false, + userData: null, + authenticationMethod: null, +}; + +// Actions +export const magicLinkRequested = email => ({ + type: 'MAGIC_LINK_REQUESTED', + payload: { email }, +}); + +export const magicLinkRequestSucceeded = () => ({ + type: 'MAGIC_LINK_REQUEST_SUCCEEDED', +}); + +export const magicLinkRequestFailed = error => ({ + type: 'MAGIC_LINK_REQUEST_FAILED', + payload: { error }, +}); + +export const magicLinkVerificationStarted = () => ({ + type: 'MAGIC_LINK_VERIFICATION_STARTED', +}); + +export const magicLinkVerificationSucceeded = userData => ({ + type: 'MAGIC_LINK_VERIFICATION_SUCCEEDED', + payload: { userData }, +}); + +export const magicLinkVerificationFailed = error => ({ + type: 'MAGIC_LINK_VERIFICATION_FAILED', + payload: { error }, +}); + +export const magicLinkExpired = () => ({ + type: 'MAGIC_LINK_EXPIRED', +}); + +export const passkeyAuthenticationStarted = () => ({ + type: 'PASSKEY_AUTHENTICATION_STARTED', +}); + +export const passkeyAuthenticationSucceeded = userData => ({ + type: 'PASSKEY_AUTHENTICATION_SUCCEEDED', + payload: { userData }, +}); + +export const passkeyAuthenticationFailed = error => ({ + type: 'PASSKEY_AUTHENTICATION_FAILED', + payload: { error }, +}); + +export const passkeyRegistrationStarted = () => ({ + type: 'PASSKEY_REGISTRATION_STARTED', +}); + +export const passkeyRegistrationSucceeded = userData => ({ + type: 'PASSKEY_REGISTRATION_SUCCEEDED', + payload: { userData }, +}); + +export const passkeyRegistrationFailed = error => ({ + type: 'PASSKEY_REGISTRATION_FAILED', + payload: { error }, +}); + +export const userSignedOut = () => ({ + type: 'USER_SIGNED_OUT', +}); + +export const reducer = (state = initialState, { type, payload } = {}) => { + switch (type) { + case magicLinkRequested().type: { + return { + ...state, + isLoading: true, + error: '', + magicLinkSent: false, + }; + } + case magicLinkRequestSucceeded().type: { + return { + ...state, + isLoading: false, + magicLinkSent: true, + }; + } + case magicLinkRequestFailed().type: { + return { + ...state, + isLoading: false, + error: payload.error, + magicLinkSent: false, + }; + } + case magicLinkVerificationStarted().type: { + return { + ...state, + isLoading: true, + error: '', + }; + } + case magicLinkVerificationSucceeded().type: { + return { + ...state, + isLoading: false, + isAuthenticated: true, + userData: payload.userData, + authenticationMethod: 'magic-link', + error: '', + }; + } + case magicLinkVerificationFailed().type: { + return { + ...state, + isLoading: false, + isAuthenticated: false, + error: payload.error, + userData: null, + authenticationMethod: null, + }; + } + case magicLinkExpired().type: { + return { + ...state, + isLoading: false, + isAuthenticated: false, + error: 'Magic link has expired or already been used', + userData: null, + authenticationMethod: null, + }; + } + case passkeyAuthenticationStarted().type: { + return { + ...state, + isLoading: true, + error: '', + }; + } + case passkeyAuthenticationSucceeded().type: { + return { + ...state, + isLoading: false, + isAuthenticated: true, + userData: payload.userData, + authenticationMethod: 'passkey', + error: '', + }; + } + case passkeyAuthenticationFailed().type: { + return { + ...state, + isLoading: false, + isAuthenticated: false, + error: payload.error, + userData: null, + authenticationMethod: null, + }; + } + case passkeyRegistrationStarted().type: { + return { + ...state, + isLoading: true, + error: '', + }; + } + case passkeyRegistrationSucceeded().type: { + return { + ...state, + isLoading: false, + userData: payload.userData, + error: '', + // Note: Registration does not set authenticationMethod or isAuthenticated + // User must still authenticate via passkey or magic link + }; + } + case passkeyRegistrationFailed().type: { + return { + ...state, + isLoading: false, + error: payload.error, + }; + } + case userSignedOut().type: { + return initialState; + } + default: { + return state; + } + } +}; + +const selectUserAuthenticationState = prop(sliceName); + +// Selectors +export const selectIsAuthenticated = pipe( + selectUserAuthenticationState, + prop('isAuthenticated'), +); +export const selectIsLoading = pipe( + selectUserAuthenticationState, + prop('isLoading'), +); +export const selectError = pipe(selectUserAuthenticationState, prop('error')); +export const selectMagicLinkSent = pipe( + selectUserAuthenticationState, + prop('magicLinkSent'), +); +export const selectUserData = pipe( + selectUserAuthenticationState, + prop('userData'), +); +export const selectAuthenticationMethod = pipe( + selectUserAuthenticationState, + prop('authenticationMethod'), +); diff --git a/src/features/authenticate/user-authentication-reducer.test.js b/src/features/authenticate/user-authentication-reducer.test.js new file mode 100644 index 0000000..bdde2e1 --- /dev/null +++ b/src/features/authenticate/user-authentication-reducer.test.js @@ -0,0 +1,884 @@ +import { assert } from 'riteway/vitest'; +import { describe, test } from 'vitest'; + +import { rootReducer } from '../../redux/root-reducer.js'; +import { createPopulatedUser } from './user-authentication-factories.js'; +import { + magicLinkExpired, + magicLinkRequested, + magicLinkRequestFailed, + magicLinkRequestSucceeded, + magicLinkVerificationFailed, + magicLinkVerificationStarted, + magicLinkVerificationSucceeded, + passkeyAuthenticationFailed, + passkeyAuthenticationStarted, + passkeyAuthenticationSucceeded, + passkeyRegistrationFailed, + passkeyRegistrationStarted, + passkeyRegistrationSucceeded, + selectAuthenticationMethod, + selectError, + selectIsAuthenticated, + selectIsLoading, + selectMagicLinkSent, + selectUserData, + userSignedOut, +} from './user-authentication-reducer.js'; + +describe('selectIsAuthenticated()', () => { + test('initial state', () => { + const rootState = rootReducer(undefined, {}); + + assert({ + given: 'the application starts', + should: 'initialize with user unauthenticated', + actual: selectIsAuthenticated(rootState), + expected: false, + }); + }); + + test('magic link verification succeeds', () => { + const userData = createPopulatedUser(); + const actions = [ + magicLinkVerificationStarted(), + magicLinkVerificationSucceeded(userData), + ]; + const state = actions.reduce(rootReducer, rootReducer(undefined, {})); + + assert({ + given: 'a magic link verification succeeds', + should: 'mark user as authenticated via magic link', + actual: selectIsAuthenticated(state), + expected: true, + }); + }); + + test('magic link verification fails', () => { + const actions = [ + magicLinkVerificationStarted(), + magicLinkVerificationFailed('Invalid or expired token'), + ]; + const state = actions.reduce(rootReducer, rootReducer(undefined, {})); + + assert({ + given: 'a magic link verification fails', + should: 'keep user unauthenticated', + actual: selectIsAuthenticated(state), + expected: false, + }); + }); + + test('passkey authentication succeeds', () => { + const userData = createPopulatedUser(); + const actions = [ + passkeyAuthenticationStarted(), + passkeyAuthenticationSucceeded(userData), + ]; + const state = actions.reduce(rootReducer, rootReducer(undefined, {})); + + assert({ + given: 'a passkey authentication succeeds', + should: 'mark user as fully authenticated and store user data', + actual: selectIsAuthenticated(state), + expected: true, + }); + }); + + test('passkey authentication fails', () => { + const actions = [ + passkeyAuthenticationStarted(), + passkeyAuthenticationFailed('Passkey verification failed'), + ]; + const state = actions.reduce(rootReducer, rootReducer(undefined, {})); + + assert({ + given: 'a passkey authentication fails', + should: 'set passkey error and keep user unauthenticated', + actual: selectIsAuthenticated(state), + expected: false, + }); + }); + + test('magic link expires after authentication', () => { + const userData = createPopulatedUser(); + const actions = [ + magicLinkVerificationSucceeded(userData), + magicLinkExpired(), + ]; + const state = actions.reduce(rootReducer, rootReducer(undefined, {})); + + assert({ + given: 'authentication expires or is invalidated', + should: 'clear user data and reset to unauthenticated state', + actual: selectIsAuthenticated(state), + expected: false, + }); + }); + + test('passkey authentication fails after success', () => { + const userData = createPopulatedUser(); + const actions = [ + passkeyAuthenticationSucceeded(userData), + passkeyAuthenticationFailed('Session invalidated'), + ]; + const state = actions.reduce(rootReducer, rootReducer(undefined, {})); + + assert({ + given: 'authentication expires or is invalidated', + should: 'clear user data and reset to unauthenticated state', + actual: selectIsAuthenticated(state), + expected: false, + }); + }); + + test('user explicitly signs out', () => { + const userData = createPopulatedUser(); + const actions = [magicLinkVerificationSucceeded(userData), userSignedOut()]; + const state = actions.reduce(rootReducer, rootReducer(undefined, {})); + + assert({ + given: 'user explicitly signs out', + should: 'clear all user state and reset authentication status', + actual: selectIsAuthenticated(state), + expected: false, + }); + }); +}); + +describe('selectIsLoading()', () => { + test('initial state', () => { + const rootState = rootReducer(undefined, {}); + + assert({ + given: 'the application starts', + should: 'initialize with no loading state', + actual: selectIsLoading(rootState), + expected: false, + }); + }); + + test('magic link requested', () => { + const state = rootReducer( + undefined, + magicLinkRequested('user@example.com'), + ); + + assert({ + given: 'a magic link request was initiated', + should: 'set loading state to true', + actual: selectIsLoading(state), + expected: true, + }); + }); + + test('magic link request succeeds', () => { + const actions = [ + magicLinkRequested('user@example.com'), + magicLinkRequestSucceeded(), + ]; + const state = actions.reduce(rootReducer, rootReducer(undefined, {})); + + assert({ + given: 'a magic link request succeeds', + should: 'clear loading state', + actual: selectIsLoading(state), + expected: false, + }); + }); + + test('magic link request fails', () => { + const actions = [ + magicLinkRequested('user@example.com'), + magicLinkRequestFailed('Network error'), + ]; + const state = actions.reduce(rootReducer, rootReducer(undefined, {})); + + assert({ + given: 'a magic link request fails', + should: 'clear loading state', + actual: selectIsLoading(state), + expected: false, + }); + }); + + test('magic link verification initiated', () => { + const state = rootReducer(undefined, magicLinkVerificationStarted()); + + assert({ + given: 'a magic link verification is initiated', + should: 'set verification loading state', + actual: selectIsLoading(state), + expected: true, + }); + }); + + test('passkey authentication initiated', () => { + const actions = [ + magicLinkRequestFailed('Previous error'), + passkeyAuthenticationStarted(), + ]; + const state = actions.reduce(rootReducer, rootReducer(undefined, {})); + + assert({ + given: 'a passkey authentication is initiated', + should: 'set passkey loading state and clear previous errors', + actual: selectIsLoading(state), + expected: true, + }); + }); + + test('passkey registration initiated', () => { + const state = rootReducer(undefined, passkeyRegistrationStarted()); + + assert({ + given: 'a passkey registration is initiated', + should: 'set passkey registration loading state', + actual: selectIsLoading(state), + expected: true, + }); + }); + + test('user explicitly signs out', () => { + const actions = [magicLinkRequested('user@example.com'), userSignedOut()]; + const state = actions.reduce(rootReducer, rootReducer(undefined, {})); + + assert({ + given: 'user explicitly signs out', + should: 'clear all user state and reset authentication status', + actual: selectIsLoading(state), + expected: false, + }); + }); +}); + +describe('selectError()', () => { + test('initial state', () => { + const rootState = rootReducer(undefined, {}); + + assert({ + given: 'the application starts', + should: 'initialize with no errors', + actual: selectError(rootState), + expected: '', + }); + }); + + test('magic link requested', () => { + const state = rootReducer( + undefined, + magicLinkRequested('user@example.com'), + ); + + assert({ + given: 'a magic link request was initiated', + should: 'clear any previous errors', + actual: selectError(state), + expected: '', + }); + }); + + test('magic link request fails', () => { + const actions = [ + magicLinkRequested('user@example.com'), + magicLinkRequestFailed('Network error'), + ]; + const state = actions.reduce(rootReducer, rootReducer(undefined, {})); + + assert({ + given: 'a magic link request fails', + should: 'set appropriate error message', + actual: selectError(state), + expected: 'Network error', + }); + }); + + test('magic link verification initiated', () => { + const actions = [ + magicLinkRequestFailed('Previous error'), + magicLinkVerificationStarted(), + ]; + const state = actions.reduce(rootReducer, rootReducer(undefined, {})); + + assert({ + given: 'a magic link verification is initiated', + should: 'clear any previous errors', + actual: selectError(state), + expected: '', + }); + }); + + test('magic link verification fails', () => { + const actions = [ + magicLinkVerificationStarted(), + magicLinkVerificationFailed('Invalid or expired token'), + ]; + const state = actions.reduce(rootReducer, rootReducer(undefined, {})); + + assert({ + given: 'a magic link verification fails', + should: 'set verification error', + actual: selectError(state), + expected: 'Invalid or expired token', + }); + }); + + test('magic link is expired or already used', () => { + const state = rootReducer(undefined, magicLinkExpired()); + + assert({ + given: 'a magic link is expired or already used', + should: 'set specific expired link error state', + actual: selectError(state), + expected: 'Magic link has expired or already been used', + }); + }); + + test('passkey authentication initiated', () => { + const actions = [ + magicLinkRequestFailed('Previous error'), + passkeyAuthenticationStarted(), + ]; + const state = actions.reduce(rootReducer, rootReducer(undefined, {})); + + assert({ + given: 'a passkey authentication is initiated', + should: 'set passkey loading state and clear previous errors', + actual: selectError(state), + expected: '', + }); + }); + + test('passkey authentication fails', () => { + const actions = [ + passkeyAuthenticationStarted(), + passkeyAuthenticationFailed('Passkey verification failed'), + ]; + const state = actions.reduce(rootReducer, rootReducer(undefined, {})); + + assert({ + given: 'a passkey authentication fails', + should: 'set passkey error and keep user unauthenticated', + actual: selectError(state), + expected: 'Passkey verification failed', + }); + }); + + test('passkey registration fails', () => { + const actions = [ + passkeyRegistrationStarted(), + passkeyRegistrationFailed('Passkey registration failed'), + ]; + const state = actions.reduce(rootReducer, rootReducer(undefined, {})); + + assert({ + given: 'a passkey registration fails', + should: 'set passkey registration error', + actual: selectError(state), + expected: 'Passkey registration failed', + }); + }); + + test('user explicitly signs out', () => { + const actions = [magicLinkRequestFailed('Network error'), userSignedOut()]; + const state = actions.reduce(rootReducer, rootReducer(undefined, {})); + + assert({ + given: 'user explicitly signs out', + should: 'clear all user state and reset authentication status', + actual: selectError(state), + expected: '', + }); + }); + + test('magic link error persists until next authentication attempt', () => { + const errorMessage = 'Magic link request failed'; + const state = rootReducer(undefined, magicLinkRequestFailed(errorMessage)); + + assert({ + given: 'any authentication error occurs', + should: 'preserve error state until next authentication attempt', + actual: selectError(state), + expected: errorMessage, + }); + }); + + test('passkey error persists until next authentication attempt', () => { + const errorMessage = 'Passkey authentication failed'; + const state = rootReducer( + undefined, + passkeyAuthenticationFailed(errorMessage), + ); + + assert({ + given: 'any authentication error occurs', + should: 'preserve error state until next authentication attempt', + actual: selectError(state), + expected: errorMessage, + }); + }); + + test('passkey registration error persists until next authentication attempt', () => { + const errorMessage = 'Passkey registration failed'; + const state = rootReducer( + undefined, + passkeyRegistrationFailed(errorMessage), + ); + + assert({ + given: 'any authentication error occurs', + should: 'preserve error state until next authentication attempt', + actual: selectError(state), + expected: errorMessage, + }); + }); + + test('magic link expired error persists until next authentication attempt', () => { + const state = rootReducer(undefined, magicLinkExpired()); + + assert({ + given: 'any authentication error occurs', + should: 'preserve error state until next authentication attempt', + actual: selectError(state), + expected: 'Magic link has expired or already been used', + }); + }); + + test('new magic link request clears previous magic link error', () => { + const actions = [ + magicLinkRequestFailed('Previous magic link error'), + magicLinkRequested('user@example.com'), + ]; + const state = actions.reduce(rootReducer, rootReducer(undefined, {})); + + assert({ + given: 'a new authentication attempt starts', + should: 'clear previous errors for that authentication method', + actual: selectError(state), + expected: '', + }); + }); + + test('new magic link verification clears previous magic link error', () => { + const actions = [ + magicLinkRequestFailed('Previous magic link error'), + magicLinkVerificationStarted(), + ]; + const state = actions.reduce(rootReducer, rootReducer(undefined, {})); + + assert({ + given: 'a new authentication attempt starts', + should: 'clear previous errors for that authentication method', + actual: selectError(state), + expected: '', + }); + }); + + test('new passkey authentication clears previous passkey error', () => { + const actions = [ + passkeyAuthenticationFailed('Previous passkey error'), + passkeyAuthenticationStarted(), + ]; + const state = actions.reduce(rootReducer, rootReducer(undefined, {})); + + assert({ + given: 'a new authentication attempt starts', + should: 'clear previous errors for that authentication method', + actual: selectError(state), + expected: '', + }); + }); + + test('new passkey registration clears previous passkey error', () => { + const actions = [ + passkeyRegistrationFailed('Previous passkey error'), + passkeyRegistrationStarted(), + ]; + const state = actions.reduce(rootReducer, rootReducer(undefined, {})); + + assert({ + given: 'a new authentication attempt starts', + should: 'clear previous errors for that authentication method', + actual: selectError(state), + expected: '', + }); + }); + + test('passkey attempt clears magic link error', () => { + const actions = [ + magicLinkRequestFailed('Magic link error'), + passkeyAuthenticationStarted(), + ]; + const state = actions.reduce(rootReducer, rootReducer(undefined, {})); + + assert({ + given: 'a new authentication attempt starts', + should: 'clear previous errors for that authentication method', + actual: selectError(state), + expected: '', + }); + }); + + test('magic link attempt clears passkey error', () => { + const actions = [ + passkeyAuthenticationFailed('Passkey error'), + magicLinkRequested('user@example.com'), + ]; + const state = actions.reduce(rootReducer, rootReducer(undefined, {})); + + assert({ + given: 'a new authentication attempt starts', + should: 'clear previous errors for that authentication method', + actual: selectError(state), + expected: '', + }); + }); + + test('magic link error does not affect passkey attempts', () => { + const actions = [ + magicLinkRequestFailed('Magic link network error'), + passkeyAuthenticationFailed('Passkey verification failed'), + ]; + const state = actions.reduce(rootReducer, rootReducer(undefined, {})); + + assert({ + given: 'multiple authentication methods fail', + should: 'track errors for each method separately', + actual: selectError(state), + expected: 'Passkey verification failed', + }); + }); + + test('passkey error does not affect magic link attempts', () => { + const actions = [ + passkeyAuthenticationFailed('Passkey verification failed'), + magicLinkRequestFailed('Magic link network error'), + ]; + const state = actions.reduce(rootReducer, rootReducer(undefined, {})); + + assert({ + given: 'multiple authentication methods fail', + should: 'track errors for each method separately', + actual: selectError(state), + expected: 'Magic link network error', + }); + }); + + test('magic link verification error overwrites request error', () => { + const actions = [ + magicLinkRequestFailed('Magic link request failed'), + magicLinkVerificationStarted(), + magicLinkVerificationFailed('Magic link verification failed'), + ]; + const state = actions.reduce(rootReducer, rootReducer(undefined, {})); + + assert({ + given: 'multiple authentication methods fail', + should: 'track errors for each method separately', + actual: selectError(state), + expected: 'Magic link verification failed', + }); + }); + + test('passkey registration error does not affect authentication error', () => { + const actions = [ + passkeyRegistrationFailed('Passkey registration failed'), + passkeyAuthenticationFailed('Passkey authentication failed'), + ]; + const state = actions.reduce(rootReducer, rootReducer(undefined, {})); + + assert({ + given: 'multiple authentication methods fail', + should: 'track errors for each method separately', + actual: selectError(state), + expected: 'Passkey authentication failed', + }); + }); + + test('expired magic link error is preserved independently', () => { + const actions = [ + passkeyAuthenticationFailed('Passkey failed'), + magicLinkExpired(), + ]; + const state = actions.reduce(rootReducer, rootReducer(undefined, {})); + + assert({ + given: 'multiple authentication methods fail', + should: 'track errors for each method separately', + actual: selectError(state), + expected: 'Magic link has expired or already been used', + }); + }); +}); + +describe('selectMagicLinkSent()', () => { + test('initial state', () => { + const rootState = rootReducer(undefined, {}); + + assert({ + given: 'the application starts', + should: 'initialize with magic link not sent', + actual: selectMagicLinkSent(rootState), + expected: false, + }); + }); + + test('magic link request succeeds', () => { + const actions = [ + magicLinkRequested('user@example.com'), + magicLinkRequestSucceeded(), + ]; + const state = actions.reduce(rootReducer, rootReducer(undefined, {})); + + assert({ + given: 'a magic link request succeeds', + should: 'set magic link as sent', + actual: selectMagicLinkSent(state), + expected: true, + }); + }); + + test('user explicitly signs out', () => { + const actions = [ + magicLinkRequested('user@example.com'), + magicLinkRequestSucceeded(), + userSignedOut(), + ]; + const state = actions.reduce(rootReducer, rootReducer(undefined, {})); + + assert({ + given: 'user explicitly signs out', + should: 'clear all user state and reset authentication status', + actual: selectMagicLinkSent(state), + expected: false, + }); + }); +}); + +describe('selectUserData()', () => { + test('initial state', () => { + const rootState = rootReducer(undefined, {}); + + assert({ + given: 'the application starts', + should: 'initialize with no user data', + actual: selectUserData(rootState), + expected: null, + }); + }); + + test('magic link verification succeeds', () => { + const userData = createPopulatedUser(); + const actions = [ + magicLinkVerificationStarted(), + magicLinkVerificationSucceeded(userData), + ]; + const state = actions.reduce(rootReducer, rootReducer(undefined, {})); + + assert({ + given: 'a magic link verification succeeds', + should: 'store user data', + actual: selectUserData(state), + expected: userData, + }); + }); + + test('magic link verification fails', () => { + const userData = createPopulatedUser(); + const actions = [ + magicLinkVerificationSucceeded(userData), + magicLinkVerificationFailed('Session expired'), + ]; + const state = actions.reduce(rootReducer, rootReducer(undefined, {})); + + assert({ + given: 'a magic link verification fails', + should: 'clear user data', + actual: selectUserData(state), + expected: null, + }); + }); + + test('passkey authentication succeeds', () => { + const userData = createPopulatedUser(); + const actions = [ + passkeyAuthenticationStarted(), + passkeyAuthenticationSucceeded(userData), + ]; + const state = actions.reduce(rootReducer, rootReducer(undefined, {})); + + assert({ + given: 'a passkey authentication succeeds', + should: 'mark user as fully authenticated and store user data', + actual: selectUserData(state), + expected: userData, + }); + }); + + test('passkey registration succeeds', () => { + const userData = createPopulatedUser(); + const actions = [ + passkeyRegistrationStarted(), + passkeyRegistrationSucceeded(userData), + ]; + const state = actions.reduce(rootReducer, rootReducer(undefined, {})); + + assert({ + given: 'a passkey registration succeeds', + should: 'update user state to include passkey capability', + actual: selectUserData(state), + expected: userData, + }); + }); + + test('magic link expires', () => { + const userData = createPopulatedUser(); + const actions = [ + magicLinkVerificationSucceeded(userData), + magicLinkExpired(), + ]; + const state = actions.reduce(rootReducer, rootReducer(undefined, {})); + + assert({ + given: 'authentication expires or is invalidated', + should: 'clear user data and reset to unauthenticated state', + actual: selectUserData(state), + expected: null, + }); + }); + + test('passkey authentication fails after success', () => { + const userData = createPopulatedUser(); + const actions = [ + passkeyAuthenticationSucceeded(userData), + passkeyAuthenticationFailed('Session invalidated'), + ]; + const state = actions.reduce(rootReducer, rootReducer(undefined, {})); + + assert({ + given: 'authentication expires or is invalidated', + should: 'clear user data and reset to unauthenticated state', + actual: selectUserData(state), + expected: null, + }); + }); + + test('user explicitly signs out', () => { + const userData = createPopulatedUser(); + const actions = [passkeyAuthenticationSucceeded(userData), userSignedOut()]; + const state = actions.reduce(rootReducer, rootReducer(undefined, {})); + + assert({ + given: 'user explicitly signs out', + should: 'clear all user state and reset authentication status', + actual: selectUserData(state), + expected: null, + }); + }); +}); + +describe('selectAuthenticationMethod()', () => { + test('initial state', () => { + const rootState = rootReducer(undefined, {}); + + assert({ + given: 'the application starts', + should: 'initialize with no authentication method', + actual: selectAuthenticationMethod(rootState), + expected: null, + }); + }); + + test('magic link verification succeeds', () => { + const userData = createPopulatedUser(); + const actions = [ + magicLinkVerificationStarted(), + magicLinkVerificationSucceeded(userData), + ]; + const state = actions.reduce(rootReducer, rootReducer(undefined, {})); + + assert({ + given: 'authentication success (any method)', + should: 'store user session data and authentication method used', + actual: selectAuthenticationMethod(state), + expected: 'magic-link', + }); + }); + + test('passkey authentication succeeds', () => { + const userData = createPopulatedUser(); + const actions = [ + passkeyAuthenticationStarted(), + passkeyAuthenticationSucceeded(userData), + ]; + const state = actions.reduce(rootReducer, rootReducer(undefined, {})); + + assert({ + given: 'authentication success (any method)', + should: 'store user session data and authentication method used', + actual: selectAuthenticationMethod(state), + expected: 'passkey', + }); + }); + + test('magic link verification fails', () => { + const userData = createPopulatedUser(); + const actions = [ + magicLinkVerificationSucceeded(userData), + magicLinkVerificationFailed('Invalid token'), + ]; + const state = actions.reduce(rootReducer, rootReducer(undefined, {})); + + assert({ + given: 'authentication expires or is invalidated', + should: 'clear user data and reset to unauthenticated state', + actual: selectAuthenticationMethod(state), + expected: null, + }); + }); + + test('magic link expires', () => { + const userData = createPopulatedUser(); + const actions = [ + magicLinkVerificationSucceeded(userData), + magicLinkExpired(), + ]; + const state = actions.reduce(rootReducer, rootReducer(undefined, {})); + + assert({ + given: 'authentication expires or is invalidated', + should: 'clear user data and reset to unauthenticated state', + actual: selectAuthenticationMethod(state), + expected: null, + }); + }); + + test('passkey authentication fails after success', () => { + const userData = createPopulatedUser(); + const actions = [ + passkeyAuthenticationSucceeded(userData), + passkeyAuthenticationFailed('Session invalidated'), + ]; + const state = actions.reduce(rootReducer, rootReducer(undefined, {})); + + assert({ + given: 'authentication expires or is invalidated', + should: 'clear user data and reset to unauthenticated state', + actual: selectAuthenticationMethod(state), + expected: null, + }); + }); + + test('user explicitly signs out', () => { + const userData = createPopulatedUser(); + const actions = [magicLinkVerificationSucceeded(userData), userSignedOut()]; + const state = actions.reduce(rootReducer, rootReducer(undefined, {})); + + assert({ + given: 'user explicitly signs out', + should: 'clear all user state and reset authentication status', + actual: selectAuthenticationMethod(state), + expected: null, + }); + }); +}); diff --git a/src/lib/utils.ts b/src/lib/utils.ts new file mode 100644 index 0000000..9ad0df4 --- /dev/null +++ b/src/lib/utils.ts @@ -0,0 +1,6 @@ +import { type ClassValue, clsx } from 'clsx'; +import { twMerge } from 'tailwind-merge'; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/src/redux/root-reducer.js b/src/redux/root-reducer.js new file mode 100644 index 0000000..90de68b --- /dev/null +++ b/src/redux/root-reducer.js @@ -0,0 +1,10 @@ +import { combineReducers } from '@reduxjs/toolkit'; + +import { + reducer as userAuthenticationReducer, + sliceName, +} from '../features/authenticate/user-authentication-reducer'; + +export const rootReducer = combineReducers({ + [sliceName]: userAuthenticationReducer, +}); diff --git a/src/styles/globals.css b/src/styles/globals.css index a2dc41e..7c66e7a 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -1,8 +1,41 @@ -@import "tailwindcss"; +@import 'tailwindcss'; +@import 'tw-animate-css'; + +@custom-variant dark (&:is(.dark *)); :root { - --background: #ffffff; - --foreground: #171717; + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --radius: 0.625rem; + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); } @theme inline { @@ -10,6 +43,39 @@ --color-foreground: var(--foreground); --font-sans: var(--font-geist-sans); --font-mono: var(--font-geist-mono); + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); } @media (prefers-color-scheme: dark) { @@ -24,3 +90,46 @@ body { color: var(--foreground); font-family: Arial, Helvetica, sans-serif; } + +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/src/utils/async-pipe.js b/src/utils/async-pipe.js new file mode 100644 index 0000000..a66e601 --- /dev/null +++ b/src/utils/async-pipe.js @@ -0,0 +1,29 @@ +/** + * Composes async functions in reverse mathematical order (pipe order). + * Takes functions as arguments and returns a new function that applies them left-to-right. + * The first function can accept multiple arguments, while subsequent functions receive single values. + * + * @param {...Function} fns - Async functions to compose + * @returns {Function} A new async function that applies the composed functions + * + * @example + * const add = async (x, y) => x + y; + * const multiply2 = async (x) => x * 2; + * const subtract1 = async (x) => x - 1; + * + * const pipeline = asyncPipe(add, multiply2, subtract1); + * const result = await pipeline(10, 5); // ((10 + 5) * 2) - 1 = 29 + */ +export const asyncPipe = + (...fns) => + (...args) => { + if (fns.length === 0) return Promise.resolve(args[0]); + + const [firstFunction, ...restFns] = fns; + const initialValue = firstFunction(...args); + + return restFns.reduce( + async (accumulator, function_) => function_(await accumulator), + initialValue, + ); + }; diff --git a/src/utils/async-pipe.test.js b/src/utils/async-pipe.test.js new file mode 100644 index 0000000..1930832 --- /dev/null +++ b/src/utils/async-pipe.test.js @@ -0,0 +1,128 @@ +import { assert } from 'riteway/vitest'; +import { describe, test } from 'vitest'; + +import { asyncPipe } from './async-pipe.js'; + +const add5 = async x => x + 5; +const multiply2 = async x => x * 2; +const subtract3 = async x => x - 3; +const divide2 = async x => x / 2; +const double = async x => x * 2; +const throwError = async () => { + throw new Error('Test error in pipeline'); +}; + +// Functions for mixed sync/async test +const addSync = x => x + 10; +const multiplyAsync = async x => x * 3; +const subtractSync = x => x - 5; +const dividePromise = x => Promise.resolve(x / 2); + +// Functions for multiple arguments test +const addThreeNumbers = async (x, y, z) => x + y + z; + +describe('asyncPipe()', () => { + test('should compose two async functions in pipe order', async () => { + const pipeline = asyncPipe(add5, multiply2); + const actual = await pipeline(10); + const expected = 30; // (10 + 5) * 2 = 30 + + assert({ + given: 'two async functions and an initial value of 10', + should: 'compose functions in reverse mathematical order (pipe order)', + actual, + expected, + }); + }); + + test('should compose multiple async functions in pipe order', async () => { + const pipeline = asyncPipe(add5, multiply2, subtract3, divide2); + const actual = await pipeline(10); + const expected = 13.5; // ((10 + 5) * 2 - 3) / 2 = (30 - 3) / 2 = 27 / 2 = 13.5 + + assert({ + given: 'four async functions and an initial value of 10', + should: 'apply functions in left-to-right order (pipe order)', + actual, + expected, + }); + }); + + test('should propagate errors from async functions', async () => { + const pipeline = asyncPipe(add5, throwError, multiply2); + + let actualError; + try { + await pipeline(10); + } catch (error) { + actualError = error.message; + } + + assert({ + given: 'a pipeline with a function that throws an error', + should: 'propagate the error and stop execution', + actual: actualError, + expected: 'Test error in pipeline', + }); + }); + + test('should handle single function', async () => { + const pipeline = asyncPipe(double); + const actual = await pipeline(5); + const expected = 10; + + assert({ + given: 'a single async function and initial value of 5', + should: 'apply the single function', + actual, + expected, + }); + }); + + test('should return identity function when no functions provided', async () => { + const pipeline = asyncPipe(); + const actual = await pipeline(42); + const expected = 42; + + assert({ + given: 'no functions and initial value of 42', + should: 'return the initial value unchanged', + actual, + expected, + }); + }); + + test('should handle mixing async and sync functions', async () => { + const pipeline = asyncPipe( + addSync, + multiplyAsync, + subtractSync, + dividePromise, + ); + const actual = await pipeline(5); + const expected = 20; // (5 + 10) * 3 - 5) / 2 = ((15 * 3) - 5) / 2 = (45 - 5) / 2 = 40 / 2 = 20 + + assert({ + given: + 'mix of sync functions, async functions, and promise-returning functions with initial value of 5', + should: 'handle all function types correctly in the pipeline', + actual, + expected, + }); + }); + + test('should handle multiple arguments for first function', async () => { + const pipeline = asyncPipe(addThreeNumbers, multiply2); + const actual = await pipeline(5, 10, 15); + const expected = 60; // (5 + 10 + 15) * 2 = 30 * 2 = 60 + + assert({ + given: + 'a pipeline with first function accepting three arguments (5, 10, 15)', + should: + 'pass all three arguments to first function and continue pipeline', + actual, + expected, + }); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 4887295..33423ef 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -23,6 +23,13 @@ "@/*": ["./src/*"] } }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + "**/*.js", + "**/*.jsx", + ".next/types/**/*.ts" + ], "exclude": ["node_modules"] } diff --git a/types/sudo.d.ts b/types/sudo.d.ts new file mode 100644 index 0000000..2f0b0eb --- /dev/null +++ b/types/sudo.d.ts @@ -0,0 +1,5 @@ +// types/sudo.d.ts +declare module '*.sudo' { + const content: string; + export default content; +} diff --git a/vitest.config.js b/vitest.config.js index 0fe449c..e3aada7 100644 --- a/vitest.config.js +++ b/vitest.config.js @@ -9,10 +9,13 @@ const rootConfig = defineConfig({ const testConfig = defineConfig({ test: { - workspace: [ + projects: [ { ...rootConfig, - test: { include: ['src/**/*.test.{js,ts}'], name: 'unit-tests' }, + test: { + include: ['src/**/*.test.{js,ts}', 'ai/**/*.test.{js,ts}'], + name: 'unit-tests', + }, }, { ...rootConfig,