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',