From da00d27049e1f2bf5cc86226f73b04beb8d1866e Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Wed, 20 May 2026 11:36:51 -0400 Subject: [PATCH] Add email/password login, fix Cognito config, enable mobile auto-deploy Apple review requires a test account login path that doesn't depend on Google OAuth. Add amazon-cognito-identity-js for direct SRP auth with a native email/password form on the login screen. Fill in the empty Cognito client ID and pool ID, fix the Cognito domain prefix, and align CDK callback URLs with the app's actual URL scheme. Enable push-triggered mobile deploys, add CDK outputs for client IDs, fix stale README references, and add mobile/README.md. --- .github/workflows/deploy-mobile.yaml | 8 +- README.md | 21 +-- infra/lib/foundation-stack.ts | 10 +- mobile/README.md | 92 ++++++++++++ mobile/package-lock.json | 182 ++++++++++++++++++++++- mobile/package.json | 1 + mobile/src/config.ts | 6 +- mobile/src/lib/api/auth.ts | 20 +++ mobile/src/lib/api/cognito-auth.ts | 66 +++++++++ mobile/src/screens/auth/LoginScreen.tsx | 189 ++++++++++++++++++++---- 10 files changed, 538 insertions(+), 57 deletions(-) create mode 100644 mobile/README.md create mode 100644 mobile/src/lib/api/cognito-auth.ts diff --git a/.github/workflows/deploy-mobile.yaml b/.github/workflows/deploy-mobile.yaml index 1f4db7c..9c35910 100644 --- a/.github/workflows/deploy-mobile.yaml +++ b/.github/workflows/deploy-mobile.yaml @@ -1,11 +1,9 @@ name: Deploy Mobile (iOS) -# Disabled during development. To activate for V1 release, change to: -# on: -# push: -# branches: [main] -# paths: ["mobile/**"] on: + push: + branches: [main] + paths: ["mobile/**"] workflow_dispatch: concurrency: diff --git a/README.md b/README.md index 47136f5..11bd291 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ Monorepo with five primary services: proposal-system/ ├── api/ .NET 8 Web API (Lambda-hosted, EF Core + PostgreSQL) ├── web/ React 19 + MUI v7 + Vite frontend -├── mobile/ React Native 0.79 iOS app +├── mobile/ React Native 0.85 iOS app ├── lambdas/ Python 3.12 processing functions (arm64) ├── infra/ CDK TypeScript (3 stacks) ├── shared/ TypeScript API contracts (shared between web + mobile) @@ -33,7 +33,7 @@ proposal-system/ |---|---| | API | .NET 8, ASP.NET Core, EF Core + Npgsql, FluentValidation, Cognito JWT, Amazon.Lambda.AspNetCoreServer | | Web | React 19, TypeScript, MUI v7, Vite, Redux Toolkit, TanStack Query, axios | -| Mobile | React Native CLI 0.79, React 19, React Native Paper, React Navigation, react-native-app-auth (PKCE), Keychain, offline draft queue | +| Mobile | React Native CLI 0.85, React 19, React Native Paper, React Navigation, react-native-app-auth (PKCE), amazon-cognito-identity-js (SRP), Keychain, offline draft queue | | Lambdas | Python 3.12, arm64, pdfplumber, reportlab, httpx, boto3 | | Infrastructure | CDK TypeScript (aws-cdk-lib 2.253.1) | | AI/RAG | Bedrock Knowledge Base (Titan Embeddings v2), OpenSearch Serverless, Claude via Bedrock Runtime | @@ -127,11 +127,13 @@ Calls `cd-cdk.yaml` reusable workflow: Deploy uses OIDC role `githubdeploy-proposal-system`. Concurrency group prevents parallel deploys. -### Mobile Deploy (currently disabled) +### Mobile Deploy -Workflow: `deploy-mobile.yaml` -- triggered by `workflow_dispatch` only (manual). +Workflow: `deploy-mobile.yaml` -- builds and uploads to TestFlight via `cd-mobile-ios.yaml` reusable workflow on `macos-26`. -To activate for release, change the trigger to push on main with path filter `mobile/**`. +Triggers: +- **Automatic**: push to `main` with changes in `mobile/**` +- **Manual**: `workflow_dispatch` for on-demand builds ## Mobile iOS @@ -147,15 +149,6 @@ Build and upload to TestFlight is handled by the `cd-mobile-ios.yaml` reusable w | `ASC_ISSUER_ID` | App Store Connect issuer | | `ASC_KEY_CONTENT` | App Store Connect API key (base64) | -To activate automatic deploys, update `deploy-mobile.yaml` trigger from `workflow_dispatch` to: - -```yaml -on: - push: - branches: [main] - paths: ["mobile/**"] -``` - ## Data Flow 1. Dispatcher submits proposal request (web or mobile) diff --git a/infra/lib/foundation-stack.ts b/infra/lib/foundation-stack.ts index 4040621..0f2ad3e 100644 --- a/infra/lib/foundation-stack.ts +++ b/infra/lib/foundation-stack.ts @@ -210,7 +210,7 @@ export class FoundationStack extends cdk.Stack { }); // Web App Client (PKCE) - userPool.addClient('WebClient', { + const webClient = userPool.addClient('WebClient', { userPoolClientName: 'proposal-system-web', generateSecret: false, authFlows: { @@ -235,7 +235,7 @@ export class FoundationStack extends cdk.Stack { }); // Mobile App Client (PKCE) - userPool.addClient('MobileClient', { + const mobileClient = userPool.addClient('MobileClient', { userPoolClientName: 'proposal-system-mobile', generateSecret: false, authFlows: { @@ -248,8 +248,8 @@ export class FoundationStack extends cdk.Stack { cognito.OAuthScope.EMAIL, cognito.OAuthScope.PROFILE, ], - callbackUrls: ['proposalsystem://callback'], - logoutUrls: ['proposalsystem://logout'], + callbackUrls: ['com.seahavenind.proposals://auth/callback'], + logoutUrls: ['com.seahavenind.proposals://auth/logout'], }, }); @@ -278,5 +278,7 @@ export class FoundationStack extends cdk.Stack { new cdk.CfnOutput(this, 'LibraryBucketName', { value: this.libraryBucket.bucketName }); new cdk.CfnOutput(this, 'JobsQueueUrl', { value: this.jobsQueue.queueUrl }); new cdk.CfnOutput(this, 'DbSecretArn', { value: this.dbSecret.secretArn }); + new cdk.CfnOutput(this, 'WebClientId', { value: webClient.userPoolClientId }); + new cdk.CfnOutput(this, 'MobileClientId', { value: mobileClient.userPoolClientId }); } } diff --git a/mobile/README.md b/mobile/README.md new file mode 100644 index 0000000..ece3134 --- /dev/null +++ b/mobile/README.md @@ -0,0 +1,92 @@ +# Proposal System — Mobile (iOS) + +React Native 0.85 iOS app for Sea Haven Industries field dispatchers. Submit proposals, capture vendor documents, and manage drafts with offline support. + +## Prerequisites + +- Node.js 24+ +- Ruby 3.x (for Fastlane) +- Xcode 26+ with iOS 26 SDK +- CocoaPods (installed via Bundler) + +## Local Development + +```bash +# Install JS dependencies +npm install + +# Install Ruby dependencies (Fastlane, CocoaPods) +bundle install + +# Install native pods +cd ios && bundle exec pod install && cd .. + +# Start Metro bundler +npm start + +# Run on iOS simulator +npm run ios +``` + +### Environment + +The app reads configuration from `src/config.ts`. In development mode (`__DEV__`), the API URL points to `http://localhost:5000/api`. Run the .NET API locally or use the development proxy. + +### Authentication + +Two login methods are supported: + +- **Email/Password** — direct Cognito SRP auth via `amazon-cognito-identity-js` +- **Google OAuth** — Cognito Hosted UI PKCE flow via `react-native-app-auth` + +The iOS URL scheme `com.seahavenind.proposals` is registered in `Info.plist` for OAuth callbacks. + +## Code Signing + +Certificates and provisioning profiles are managed by **Fastlane Match** using S3 storage: + +- **Bucket**: `seahaven-ios-certificates` (us-east-1) +- **Bundle ID**: `com.seahavenind.proposals` +- **Team ID**: `9KAQYC653W` + +Match is configured in `fastlane/Matchfile`. The `MATCH_PASSWORD` secret decrypts signing assets. + +## CI/CD + +The `deploy-mobile.yaml` workflow triggers on push to `main` (with `mobile/**` path filter) or manual `workflow_dispatch`. It calls the `cd-mobile-ios.yaml` reusable workflow which: + +1. Sets up `macos-26` runner with Xcode 26 +2. Installs dependencies and pods +3. Retrieves signing assets via Match (S3) +4. Builds the IPA with Fastlane +5. Uploads to TestFlight + +### Required GitHub Secrets + +| Secret | Purpose | +|---|---| +| `AWS_DEPLOY_ROLE_ARN` | OIDC role for Match S3 access | +| `MATCH_PASSWORD` | Signing asset decryption passphrase | +| `ASC_KEY_ID` | App Store Connect API key ID | +| `ASC_ISSUER_ID` | App Store Connect API issuer | +| `ASC_KEY_CONTENT` | App Store Connect `.p8` key (base64) | + +## Project Structure + +``` +mobile/ +├── src/ +│ ├── screens/ Auth, dispatcher, and admin screens +│ ├── lib/api/ API clients (auth, proposals, line items, admin) +│ ├── store/ Redux Toolkit (auth slice) +│ ├── navigation/ React Navigation (RootNavigator) +│ ├── components/ Reusable UI components +│ ├── hooks/ useAuth, useOfflineDraft, usePaginatedList +│ ├── theme/ Material Design 3 theming +│ ├── constants/ App-wide constants +│ └── config.ts Cognito + API configuration +├── ios/ Xcode project, assets, Info.plist +├── fastlane/ Fastfile, Matchfile, Appfile +├── Gemfile Ruby dependencies +└── package.json React Native 0.85.3 +``` diff --git a/mobile/package-lock.json b/mobile/package-lock.json index 036633c..672018a 100644 --- a/mobile/package-lock.json +++ b/mobile/package-lock.json @@ -16,6 +16,7 @@ "@react-navigation/native-stack": "^7.2.0", "@reduxjs/toolkit": "^2.11.2", "@tanstack/react-query": "^5.100.10", + "amazon-cognito-identity-js": "^6.3.0", "axios": "^1.16.0", "react": "^19.0.0", "react-native": "^0.85.3", @@ -48,6 +49,62 @@ "typescript": "~5.7.0" } }, + "node_modules/@aws-crypto/sha256-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-1.2.2.tgz", + "integrity": "sha512-Nr1QJIbW/afYYGzYvrF70LtaHrIRtd4TNAglX8BvlfxJLZ45SAmueIKYl5tWoNBPzp65ymXGFK0Bb1vZUpuc9g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^1.2.2", + "@aws-sdk/types": "^3.1.0", + "tslib": "^1.11.1" + } + }, + "node_modules/@aws-crypto/util": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-1.2.2.tgz", + "integrity": "sha512-H8PjG5WJ4wz0UXAFXeJjWCW1vkvIJ3qUUD+rGRwJ2/hj+xT58Qle2MTql/2MGzkU+1JLAFuR6aJpLAjHwhmwwg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.1.0", + "@aws-sdk/util-utf8-browser": "^3.0.0", + "tslib": "^1.11.1" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.973.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.8.tgz", + "integrity": "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@aws-sdk/util-utf8-browser": { + "version": "3.259.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-utf8-browser/-/util-utf8-browser-3.259.0.tgz", + "integrity": "sha512-UvFa/vR+e19XookZF8RzFZBrw2EUkQWxiBW0yYQAhvk3C+QVGl0H3ouca8LDBlBfQKXwmW3huo/59H8rwb1wJw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.3.1" + } + }, + "node_modules/@aws-sdk/util-utf8-browser/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -2139,6 +2196,24 @@ "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", "license": "MIT" }, + "node_modules/@smithy/types": { + "version": "4.14.2", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.2.tgz", + "integrity": "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -2319,6 +2394,30 @@ "node": ">= 6.0.0" } }, + "node_modules/amazon-cognito-identity-js": { + "version": "6.3.16", + "resolved": "https://registry.npmjs.org/amazon-cognito-identity-js/-/amazon-cognito-identity-js-6.3.16.tgz", + "integrity": "sha512-HPGSBGD6Q36t99puWh0LnptxO/4icnk2kqIQ9cTJ2tFQo5NMUnWQIgtrTAk8nm+caqUbjDzXzG56GBjI2tS6jQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-js": "1.2.2", + "buffer": "4.9.2", + "fast-base64-decode": "^1.0.0", + "isomorphic-unfetch": "^3.0.0", + "js-cookie": "^2.2.1" + } + }, + "node_modules/amazon-cognito-identity-js/node_modules/buffer": { + "version": "4.9.2", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-4.9.2.tgz", + "integrity": "sha512-xq+q3SRMOxGivLhBNaUdC64hDTQwejJ+H0T/NB1XMtTVEwNTrfFF3gAxiyW0Bu/xWEGhjVKgUcMhCrUy2+uCWg==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.0.2", + "ieee754": "^1.1.4", + "isarray": "^1.0.0" + } + }, "node_modules/anser": { "version": "1.4.10", "resolved": "https://registry.npmjs.org/anser/-/anser-1.4.10.tgz", @@ -3365,6 +3464,12 @@ "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", "license": "Apache-2.0" }, + "node_modules/fast-base64-decode": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fast-base64-decode/-/fast-base64-decode-1.0.0.tgz", + "integrity": "sha512-qwaScUgUGBYeDNRnbc/KyllVU88Jk1pRHPStuF/lO7B0/RTRLj7U0lkdTAutlBblY08rwZDff6tNU9cjv6j//Q==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3875,7 +3980,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "dev": true, "funding": [ { "type": "github", @@ -4068,12 +4172,28 @@ "node": ">=8" } }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, + "node_modules/isomorphic-unfetch": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/isomorphic-unfetch/-/isomorphic-unfetch-3.1.0.tgz", + "integrity": "sha512-geDJjpoZ8N0kWexiwkX8F9NkTsXhetLPVbZFQ+JTW239QNOwvB0gniuR1Wc6f0AMTn7/mFGyXvHTifrCp/GH8Q==", + "license": "MIT", + "dependencies": { + "node-fetch": "^2.6.1", + "unfetch": "^4.2.0" + } + }, "node_modules/jest-get-type": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", @@ -4176,6 +4296,12 @@ "@sideway/pinpoint": "^2.0.0" } }, + "node_modules/js-cookie": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-2.2.1.tgz", + "integrity": "sha512-HvdH2LzI/EAZcUwA8+0nKNtWHqS+ZmijLA30RwZA0bo7ToCckjK5MkGhjED9KoRcXO6BaGI3I9UIzSA1FKFPOQ==", + "license": "MIT" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -4998,6 +5124,26 @@ "node": ">=12.0.0" } }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -6599,6 +6745,18 @@ "node": ">=0.6" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, "node_modules/type-fest": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.7.1.tgz", @@ -6688,6 +6846,12 @@ "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", "license": "MIT" }, + "node_modules/unfetch": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/unfetch/-/unfetch-4.2.0.tgz", + "integrity": "sha512-F9p7yYCn6cIW9El1zi0HI6vqpeIvBsr3dSuRO6Xuppb1u5rXpCPmMvLSyECLhybr9isec8Ohl0hPekMVrEinDA==", + "license": "MIT" + }, "node_modules/unicode-canonical-property-names-ecmascript": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", @@ -6856,12 +7020,28 @@ "defaults": "^1.0.3" } }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, "node_modules/whatwg-fetch": { "version": "3.6.20", "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz", "integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==", "license": "MIT" }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/mobile/package.json b/mobile/package.json index 90b4790..8f3078c 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -11,6 +11,7 @@ "dependencies": { "@proposal-system/api-contracts": "file:../shared/api-contracts", "@react-native-async-storage/async-storage": "^2.1.0", + "amazon-cognito-identity-js": "^6.3.0", "@react-native-community/netinfo": "^12.0.1", "@react-navigation/bottom-tabs": "^7.2.0", "@react-navigation/native": "^7.0.0", diff --git a/mobile/src/config.ts b/mobile/src/config.ts index 2f6ed0a..0d7cc0e 100644 --- a/mobile/src/config.ts +++ b/mobile/src/config.ts @@ -2,9 +2,9 @@ const Config = { API_URL: __DEV__ ? 'http://localhost:5000/api' : 'https://api.proposals.seahaven.com/api', - COGNITO_DOMAIN: 'proposal-system.auth.us-east-1.amazoncognito.com', - COGNITO_CLIENT_ID: '', - COGNITO_USER_POOL_ID: '', + COGNITO_DOMAIN: 'proposal-system-seahaven.auth.us-east-1.amazoncognito.com', + COGNITO_CLIENT_ID: '3egjbljml6o9qg3q155t784018', + COGNITO_USER_POOL_ID: 'us-east-1_DfWcl2q5z', COGNITO_REDIRECT_URI: 'com.seahavenind.proposals://auth/callback', COGNITO_SCOPES: ['openid', 'email', 'profile'], }; diff --git a/mobile/src/lib/api/auth.ts b/mobile/src/lib/api/auth.ts index 8ca649c..24ea9a3 100644 --- a/mobile/src/lib/api/auth.ts +++ b/mobile/src/lib/api/auth.ts @@ -1,6 +1,7 @@ import { authorize, refresh, revoke } from 'react-native-app-auth'; import Config from '../../config'; import apiClient from './client'; +import { authenticateWithCredentials } from './cognito-auth'; import { tokenStorage, userStorage, @@ -38,6 +39,25 @@ export const authApi = { return profile; }, + loginWithCredentials: async ( + email: string, + password: string, + ): Promise => { + const cognitoTokens = await authenticateWithCredentials(email, password); + + const tokens: StoredTokens = { + accessToken: cognitoTokens.accessToken, + idToken: cognitoTokens.idToken, + refreshToken: cognitoTokens.refreshToken, + expiresAt: cognitoTokens.expiresAt, + }; + await tokenStorage.save(tokens); + + const profile = await authApi.getMe(); + await userStorage.save(profile); + return profile; + }, + refreshTokens: async (): Promise => { const stored = await tokenStorage.get(); if (!stored?.refreshToken) throw new Error('No refresh token'); diff --git a/mobile/src/lib/api/cognito-auth.ts b/mobile/src/lib/api/cognito-auth.ts new file mode 100644 index 0000000..5bf13db --- /dev/null +++ b/mobile/src/lib/api/cognito-auth.ts @@ -0,0 +1,66 @@ +import { + CognitoUserPool, + CognitoUser, + AuthenticationDetails, + CognitoUserSession, +} from 'amazon-cognito-identity-js'; +import Config from '../../config'; + +const userPool = new CognitoUserPool({ + UserPoolId: Config.COGNITO_USER_POOL_ID, + ClientId: Config.COGNITO_CLIENT_ID, +}); + +export interface CognitoTokens { + accessToken: string; + idToken: string; + refreshToken: string; + expiresAt: string; +} + +function extractTokens(session: CognitoUserSession): CognitoTokens { + const accessToken = session.getAccessToken(); + return { + accessToken: accessToken.getJwtToken(), + idToken: session.getIdToken().getJwtToken(), + refreshToken: session.getRefreshToken().getToken(), + expiresAt: new Date(accessToken.getExpiration() * 1000).toISOString(), + }; +} + +export function authenticateWithCredentials( + email: string, + password: string, +): Promise { + return new Promise((resolve, reject) => { + const cognitoUser = new CognitoUser({ + Username: email, + Pool: userPool, + }); + + const authDetails = new AuthenticationDetails({ + Username: email, + Password: password, + }); + + cognitoUser.authenticateUser(authDetails, { + onSuccess: (session) => { + resolve(extractTokens(session)); + }, + onFailure: (err) => { + if (err.code === 'NotAuthorizedException') { + reject(new Error('Incorrect email or password.')); + } else if (err.code === 'UserNotFoundException') { + reject(new Error('No account found with that email.')); + } else if (err.code === 'UserNotConfirmedException') { + reject(new Error('Account not confirmed. Contact your administrator.')); + } else { + reject(new Error(err.message || 'Authentication failed.')); + } + }, + newPasswordRequired: () => { + reject(new Error('Password change required. Contact your administrator.')); + }, + }); + }); +} diff --git a/mobile/src/screens/auth/LoginScreen.tsx b/mobile/src/screens/auth/LoginScreen.tsx index 57cd14b..bdc7028 100644 --- a/mobile/src/screens/auth/LoginScreen.tsx +++ b/mobile/src/screens/auth/LoginScreen.tsx @@ -1,23 +1,67 @@ import React, { useState } from 'react'; -import { View, StyleSheet } from 'react-native'; -import { Button, Text, Surface } from 'react-native-paper'; +import { + View, + StyleSheet, + KeyboardAvoidingView, + Platform, + ScrollView, +} from 'react-native'; +import { + Button, + Text, + Surface, + TextInput, + Divider, + HelperText, +} from 'react-native-paper'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useDispatch } from 'react-redux'; import { authApi } from '../../lib/api/auth'; -import { setUser, setError } from '../../store/slices/authSlice'; +import { setUser, setError, clearError } from '../../store/slices/authSlice'; export function LoginScreen() { const dispatch = useDispatch(); const [loading, setLoading] = useState(false); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [showPassword, setShowPassword] = useState(false); + const [localError, setLocalError] = useState(''); - const handleLogin = async () => { + const handleGoogleLogin = async () => { setLoading(true); + setLocalError(''); + dispatch(clearError()); try { const user = await authApi.login(); dispatch(setUser(user)); } catch (err) { const message = err instanceof Error ? err.message : 'Login failed. Please try again.'; + setLocalError(message); + dispatch(setError(message)); + } finally { + setLoading(false); + } + }; + + const handleCredentialLogin = async () => { + if (!email.trim() || !password) { + setLocalError('Email and password are required.'); + return; + } + setLoading(true); + setLocalError(''); + dispatch(clearError()); + try { + const user = await authApi.loginWithCredentials( + email.trim().toLowerCase(), + password, + ); + dispatch(setUser(user)); + } catch (err) { + const message = + err instanceof Error ? err.message : 'Login failed. Please try again.'; + setLocalError(message); dispatch(setError(message)); } finally { setLoading(false); @@ -26,28 +70,92 @@ export function LoginScreen() { return ( - - - - Sea Haven - - - Industries - - + + + + + Sea Haven + + + Industries + + - - Proposal System - - - Submit and manage proposals from anywhere - + + Proposal System + + + Submit and manage proposals from anywhere + + + + + setShowPassword(!showPassword)} + /> + } + /> + + {localError ? ( + + {localError} + + ) : null} + + + + + + + + OR + + + - - - - Use your Sea Haven Workspace account - - + + Use your Sea Haven email to sign in + + + ); } @@ -70,11 +178,15 @@ const styles = StyleSheet.create({ flex: 1, backgroundColor: '#FAFAFA', }, - content: { + flex: { flex: 1, + }, + content: { + flexGrow: 1, justifyContent: 'center', alignItems: 'center', paddingHorizontal: 32, + paddingVertical: 24, }, logoContainer: { alignItems: 'center', @@ -97,16 +209,33 @@ const styles = StyleSheet.create({ subtitle: { color: '#757575', textAlign: 'center', - marginBottom: 48, + marginBottom: 32, }, - actions: { + form: { width: '100%', }, + input: { + marginBottom: 12, + }, buttonContent: { paddingVertical: 8, }, button: { borderRadius: 8, + width: '100%', + }, + dividerRow: { + flexDirection: 'row', + alignItems: 'center', + width: '100%', + marginVertical: 20, + }, + dividerLine: { + flex: 1, + }, + dividerText: { + color: '#9E9E9E', + marginHorizontal: 16, }, footer: { color: '#9E9E9E',