Compare commits
1 commit
c0b55c91bc
...
e8fdc73c3a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e8fdc73c3a |
8
.github/workflows/deploy-mobile.yaml
vendored
|
|
@ -1,9 +1,11 @@
|
|||
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:
|
||||
|
|
|
|||
21
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.85 iOS app
|
||||
├── mobile/ React Native 0.79 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.85, React 19, React Native Paper, React Navigation, react-native-app-auth (PKCE), amazon-cognito-identity-js (SRP), Keychain, offline draft queue |
|
||||
| Mobile | React Native CLI 0.79, React 19, React Native Paper, React Navigation, react-native-app-auth (PKCE), 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,13 +127,11 @@ Calls `cd-cdk.yaml` reusable workflow:
|
|||
|
||||
Deploy uses OIDC role `githubdeploy-proposal-system`. Concurrency group prevents parallel deploys.
|
||||
|
||||
### Mobile Deploy
|
||||
### Mobile Deploy (currently disabled)
|
||||
|
||||
Workflow: `deploy-mobile.yaml` -- builds and uploads to TestFlight via `cd-mobile-ios.yaml` reusable workflow on `macos-26`.
|
||||
Workflow: `deploy-mobile.yaml` -- triggered by `workflow_dispatch` only (manual).
|
||||
|
||||
Triggers:
|
||||
- **Automatic**: push to `main` with changes in `mobile/**`
|
||||
- **Manual**: `workflow_dispatch` for on-demand builds
|
||||
To activate for release, change the trigger to push on main with path filter `mobile/**`.
|
||||
|
||||
## Mobile iOS
|
||||
|
||||
|
|
@ -149,6 +147,15 @@ 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)
|
||||
|
|
|
|||
|
|
@ -210,7 +210,7 @@ export class FoundationStack extends cdk.Stack {
|
|||
});
|
||||
|
||||
// Web App Client (PKCE)
|
||||
const webClient = userPool.addClient('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)
|
||||
const mobileClient = userPool.addClient('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: ['com.seahavenind.proposals://auth/callback'],
|
||||
logoutUrls: ['com.seahavenind.proposals://auth/logout'],
|
||||
callbackUrls: ['proposalsystem://callback'],
|
||||
logoutUrls: ['proposalsystem://logout'],
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -278,7 +278,5 @@ 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 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,92 +0,0 @@
|
|||
# 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
|
||||
```
|
||||
|
|
@ -1,55 +1,46 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "Icon-40.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "2x",
|
||||
"size" : "20x20"
|
||||
},
|
||||
{
|
||||
"filename" : "Icon-60.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "3x",
|
||||
"size" : "20x20"
|
||||
},
|
||||
{
|
||||
"filename" : "Icon-58.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "2x",
|
||||
"size" : "29x29"
|
||||
},
|
||||
{
|
||||
"filename" : "Icon-87.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "3x",
|
||||
"size" : "29x29"
|
||||
},
|
||||
{
|
||||
"filename" : "Icon-80.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "2x",
|
||||
"size" : "40x40"
|
||||
},
|
||||
{
|
||||
"filename" : "Icon-120.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "3x",
|
||||
"size" : "40x40"
|
||||
},
|
||||
{
|
||||
"filename" : "Icon-120.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "2x",
|
||||
"size" : "60x60"
|
||||
},
|
||||
{
|
||||
"filename" : "Icon-180.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "3x",
|
||||
"size" : "60x60"
|
||||
},
|
||||
{
|
||||
"filename" : "Icon-1024.png",
|
||||
"idiom" : "ios-marketing",
|
||||
"scale" : "1x",
|
||||
"size" : "1024x1024"
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 6 KiB |
|
Before Width: | Height: | Size: 292 B |
|
Before Width: | Height: | Size: 695 B |
|
Before Width: | Height: | Size: 105 B |
|
Before Width: | Height: | Size: 133 B |
|
Before Width: | Height: | Size: 139 B |
|
Before Width: | Height: | Size: 174 B |
|
Before Width: | Height: | Size: 192 B |
|
|
@ -84,7 +84,5 @@
|
|||
</array>
|
||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||
<false/>
|
||||
<key>CFBundleIconName</key>
|
||||
<string>AppIcon</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
|
|||
190
mobile/package-lock.json
generated
|
|
@ -10,13 +10,12 @@
|
|||
"dependencies": {
|
||||
"@proposal-system/api-contracts": "file:../shared/api-contracts",
|
||||
"@react-native-async-storage/async-storage": "^2.1.0",
|
||||
"@react-native-community/netinfo": "^12.0.1",
|
||||
"@react-native-community/netinfo": "^11.4.0",
|
||||
"@react-navigation/bottom-tabs": "^7.2.0",
|
||||
"@react-navigation/native": "^7.0.0",
|
||||
"@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",
|
||||
|
|
@ -49,62 +48,6 @@
|
|||
"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",
|
||||
|
|
@ -1764,9 +1707,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@react-native-community/netinfo": {
|
||||
"version": "12.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@react-native-community/netinfo/-/netinfo-12.0.1.tgz",
|
||||
"integrity": "sha512-P/3caXIvfYSJG8AWJVefukg+ZGRPs+M4Lp3pNJtgcTYoJxCjWrKQGNnCkj/Cz//zWa/avGed0i/wzm0T8vV2IQ==",
|
||||
"version": "11.5.2",
|
||||
"resolved": "https://registry.npmjs.org/@react-native-community/netinfo/-/netinfo-11.5.2.tgz",
|
||||
"integrity": "sha512-/g0m65BtX9HU+bPiCH2517bOHpEIUsGrWFXDzi1a5nNKn5KujQgm04WhL7/OSXWKHyrT8VVtUoJA0XKRxueBpQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "*",
|
||||
|
|
@ -2196,24 +2139,6 @@
|
|||
"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",
|
||||
|
|
@ -2394,30 +2319,6 @@
|
|||
"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",
|
||||
|
|
@ -3464,12 +3365,6 @@
|
|||
"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",
|
||||
|
|
@ -3980,6 +3875,7 @@
|
|||
"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",
|
||||
|
|
@ -4172,28 +4068,12 @@
|
|||
"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",
|
||||
|
|
@ -4296,12 +4176,6 @@
|
|||
"@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",
|
||||
|
|
@ -5124,26 +4998,6 @@
|
|||
"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",
|
||||
|
|
@ -6745,18 +6599,6 @@
|
|||
"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",
|
||||
|
|
@ -6846,12 +6688,6 @@
|
|||
"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",
|
||||
|
|
@ -7020,28 +6856,12 @@
|
|||
"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",
|
||||
|
|
|
|||
|
|
@ -11,8 +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-native-community/netinfo": "^11.4.0",
|
||||
"@react-navigation/bottom-tabs": "^7.2.0",
|
||||
"@react-navigation/native": "^7.0.0",
|
||||
"@react-navigation/native-stack": "^7.2.0",
|
||||
|
|
|
|||
|
|
@ -41,16 +41,14 @@ function AuthBootstrap({ children }: { children: React.ReactNode }) {
|
|||
if (state.isConnected) {
|
||||
processOfflineQueue((data) =>
|
||||
proposalsApi.create(data as CreateProposalRequest),
|
||||
)
|
||||
.then((count) => {
|
||||
if (count > 0) {
|
||||
Alert.alert(
|
||||
'Synced',
|
||||
`${count} offline proposal${count > 1 ? 's' : ''} submitted.`,
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
).then((count) => {
|
||||
if (count > 0) {
|
||||
Alert.alert(
|
||||
'Synced',
|
||||
`${count} offline proposal${count > 1 ? 's' : ''} submitted.`,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
return unsubscribe;
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@ const Config = {
|
|||
API_URL: __DEV__
|
||||
? 'http://localhost:5000/api'
|
||||
: 'https://api.proposals.seahaven.com/api',
|
||||
COGNITO_DOMAIN: 'proposal-system-seahaven.auth.us-east-1.amazoncognito.com',
|
||||
COGNITO_CLIENT_ID: '3egjbljml6o9qg3q155t784018',
|
||||
COGNITO_USER_POOL_ID: 'us-east-1_DfWcl2q5z',
|
||||
COGNITO_DOMAIN: 'proposal-system.auth.us-east-1.amazoncognito.com',
|
||||
COGNITO_CLIENT_ID: '',
|
||||
COGNITO_USER_POOL_ID: '',
|
||||
COGNITO_REDIRECT_URI: 'com.seahavenind.proposals://auth/callback',
|
||||
COGNITO_SCOPES: ['openid', 'email', 'profile'],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
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,
|
||||
|
|
@ -39,25 +38,6 @@ export const authApi = {
|
|||
return profile;
|
||||
},
|
||||
|
||||
loginWithCredentials: async (
|
||||
email: string,
|
||||
password: string,
|
||||
): Promise<StoredUser> => {
|
||||
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<void> => {
|
||||
const stored = await tokenStorage.get();
|
||||
if (!stored?.refreshToken) throw new Error('No refresh token');
|
||||
|
|
|
|||
|
|
@ -1,66 +0,0 @@
|
|||
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<CognitoTokens> {
|
||||
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.'));
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -1,67 +1,23 @@
|
|||
import React, { useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
StyleSheet,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
ScrollView,
|
||||
} from 'react-native';
|
||||
import {
|
||||
Button,
|
||||
Text,
|
||||
Surface,
|
||||
TextInput,
|
||||
Divider,
|
||||
HelperText,
|
||||
} from 'react-native-paper';
|
||||
import { View, StyleSheet } from 'react-native';
|
||||
import { Button, Text, Surface } 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, clearError } from '../../store/slices/authSlice';
|
||||
import { setUser, setError } 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 handleGoogleLogin = async () => {
|
||||
const handleLogin = 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);
|
||||
|
|
@ -70,92 +26,28 @@ export function LoginScreen() {
|
|||
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<KeyboardAvoidingView
|
||||
style={styles.flex}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
>
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.content}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
<Surface style={styles.logoContainer} elevation={0}>
|
||||
<Text variant="headlineLarge" style={styles.brand}>
|
||||
Sea Haven
|
||||
</Text>
|
||||
<Text variant="titleMedium" style={styles.brandSub}>
|
||||
Industries
|
||||
</Text>
|
||||
</Surface>
|
||||
|
||||
<Text variant="headlineSmall" style={styles.title}>
|
||||
Proposal System
|
||||
<View style={styles.content}>
|
||||
<Surface style={styles.logoContainer} elevation={0}>
|
||||
<Text variant="headlineLarge" style={styles.brand}>
|
||||
Sea Haven
|
||||
</Text>
|
||||
<Text variant="bodyLarge" style={styles.subtitle}>
|
||||
Submit and manage proposals from anywhere
|
||||
<Text variant="titleMedium" style={styles.brandSub}>
|
||||
Industries
|
||||
</Text>
|
||||
</Surface>
|
||||
|
||||
<View style={styles.form}>
|
||||
<TextInput
|
||||
label="Email"
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
autoCapitalize="none"
|
||||
autoComplete="email"
|
||||
keyboardType="email-address"
|
||||
textContentType="emailAddress"
|
||||
mode="outlined"
|
||||
disabled={loading}
|
||||
style={styles.input}
|
||||
/>
|
||||
<TextInput
|
||||
label="Password"
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
secureTextEntry={!showPassword}
|
||||
autoCapitalize="none"
|
||||
autoComplete="password"
|
||||
textContentType="password"
|
||||
mode="outlined"
|
||||
disabled={loading}
|
||||
style={styles.input}
|
||||
right={
|
||||
<TextInput.Icon
|
||||
icon={showPassword ? 'eye-off' : 'eye'}
|
||||
onPress={() => setShowPassword(!showPassword)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
{localError ? (
|
||||
<HelperText type="error" visible>
|
||||
{localError}
|
||||
</HelperText>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
mode="contained"
|
||||
onPress={handleCredentialLogin}
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
contentStyle={styles.buttonContent}
|
||||
style={styles.button}
|
||||
>
|
||||
Sign In
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
<View style={styles.dividerRow}>
|
||||
<Divider style={styles.dividerLine} />
|
||||
<Text variant="bodySmall" style={styles.dividerText}>
|
||||
OR
|
||||
</Text>
|
||||
<Divider style={styles.dividerLine} />
|
||||
</View>
|
||||
<Text variant="headlineSmall" style={styles.title}>
|
||||
Proposal System
|
||||
</Text>
|
||||
<Text variant="bodyLarge" style={styles.subtitle}>
|
||||
Submit and manage proposals from anywhere
|
||||
</Text>
|
||||
|
||||
<View style={styles.actions}>
|
||||
<Button
|
||||
mode="outlined"
|
||||
mode="contained"
|
||||
icon="google"
|
||||
onPress={handleGoogleLogin}
|
||||
onPress={handleLogin}
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
contentStyle={styles.buttonContent}
|
||||
|
|
@ -163,12 +55,12 @@ export function LoginScreen() {
|
|||
>
|
||||
Sign in with Google
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
<Text variant="bodySmall" style={styles.footer}>
|
||||
Use your Sea Haven email to sign in
|
||||
</Text>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
<Text variant="bodySmall" style={styles.footer}>
|
||||
Use your Sea Haven Workspace account
|
||||
</Text>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
|
@ -178,15 +70,11 @@ const styles = StyleSheet.create({
|
|||
flex: 1,
|
||||
backgroundColor: '#FAFAFA',
|
||||
},
|
||||
flex: {
|
||||
flex: 1,
|
||||
},
|
||||
content: {
|
||||
flexGrow: 1,
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 32,
|
||||
paddingVertical: 24,
|
||||
},
|
||||
logoContainer: {
|
||||
alignItems: 'center',
|
||||
|
|
@ -209,33 +97,16 @@ const styles = StyleSheet.create({
|
|||
subtitle: {
|
||||
color: '#757575',
|
||||
textAlign: 'center',
|
||||
marginBottom: 32,
|
||||
marginBottom: 48,
|
||||
},
|
||||
form: {
|
||||
actions: {
|
||||
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',
|
||||
|
|
|
|||