Compare commits

..

1 commit

Author SHA1 Message Date
dependabot[bot]
e8fdc73c3a
Merge 635746307f into fa93870a99 2026-05-19 23:22:40 +00:00
21 changed files with 68 additions and 562 deletions

View file

@ -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:

View file

@ -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)

View file

@ -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 });
}
}

View file

@ -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
```

View file

@ -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"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 292 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 695 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 133 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 174 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 192 B

View file

@ -84,7 +84,5 @@
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
<key>CFBundleIconName</key>
<string>AppIcon</string>
</dict>
</plist>

190
mobile/package-lock.json generated
View file

@ -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",

View file

@ -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",

View file

@ -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;

View file

@ -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'],
};

View file

@ -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');

View file

@ -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.'));
},
});
});
}

View file

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