6 Commits

Author SHA1 Message Date
Ada
fb1377eb4b Generate SSS shares from mnemonic words 2026-01-30 16:31:09 -08:00
Ada
c07f1f20d5 fix the warning 2026-01-30 15:22:13 -08:00
749ed2f05a Merge tab 2026-01-29 22:51:10 -04:00
Ada
da4a7de0ad improve tab structure 2026-01-28 17:24:15 -08:00
Ada
146320052e update flow and auth model 2026-01-28 16:27:00 -08:00
lusixing
4d94888bb8 added login and register 2026-01-27 19:52:36 -08:00
21 changed files with 4305 additions and 686 deletions

65
App.tsx
View File

@@ -1,17 +1,60 @@
/**
* App Entry Point
*
* Main application component with authentication routing.
* Shows loading screen while restoring auth state.
*/
import React from 'react'; import React from 'react';
import { StatusBar } from 'expo-status-bar'; import { StatusBar } from 'expo-status-bar';
import { NavigationContainer } from '@react-navigation/native'; import { NavigationContainer } from '@react-navigation/native';
import { GestureHandlerRootView } from 'react-native-gesture-handler'; import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { StyleSheet } from 'react-native'; import { StyleSheet, View, ActivityIndicator, Text } from 'react-native';
import TabNavigator from './src/navigation/TabNavigator'; import TabNavigator from './src/navigation/TabNavigator';
import AuthNavigator from './src/navigation/AuthNavigator';
import { AuthProvider, useAuth } from './src/context/AuthContext';
import { colors } from './src/theme/colors';
/**
* Loading screen shown while restoring auth state
*/
function LoadingScreen() {
return (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={colors.nautical.teal} />
<Text style={styles.loadingText}>Loading...</Text>
</View>
);
}
/**
* Main app content with auth-based routing
*/
function AppContent() {
const { user, isInitializing } = useAuth();
// Show loading screen while restoring auth state
if (isInitializing) {
return <LoadingScreen />;
}
return (
<NavigationContainer>
<StatusBar style="auto" />
{user ? <TabNavigator /> : <AuthNavigator />}
</NavigationContainer>
);
}
/**
* Root App component
*/
export default function App() { export default function App() {
return ( return (
<GestureHandlerRootView style={styles.container}> <GestureHandlerRootView style={styles.container}>
<NavigationContainer> <AuthProvider>
<StatusBar style="auto" /> <AppContent />
<TabNavigator /> </AuthProvider>
</NavigationContainer>
</GestureHandlerRootView> </GestureHandlerRootView>
); );
} }
@@ -19,5 +62,17 @@ export default function App() {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
flex: 1, flex: 1,
backgroundColor: '#000',
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: colors.flow.backgroundGradientStart,
},
loadingText: {
marginTop: 16,
fontSize: 16,
color: colors.flow.textSecondary,
}, },
}); });

View File

@@ -44,8 +44,35 @@ Sentinel is a mobile application that helps users securely manage their digital
- **Framework**: React Native (Expo SDK 52) - **Framework**: React Native (Expo SDK 52)
- **Language**: TypeScript - **Language**: TypeScript
- **Navigation**: React Navigation (Bottom Tabs) - **Navigation**: React Navigation (Bottom Tabs)
- **Icons**: @expo/vector-icons (Feather, Ionicons, MaterialCommunityIcons, FontAwesome5) - **Icons**: @expo/vector-icons (Feather, Ionicons, FontAwesome5)
- **Styling**: Custom nautical theme with gradients - **Styling**: Custom nautical theme with gradients
- **State Management**: React Context (AuthContext)
- **Storage**: AsyncStorage for auth persistence
## Configuration
The application uses a centralized configuration file located at `src/config/index.ts`.
### Key Configuration Options
| Option | Description | Default |
|--------|-------------|---------|
| `NO_BACKEND_MODE` | Use mock data instead of real backend | `false` |
| `DEBUG_MODE` | Enable API debug logging | `true` |
| `API_BASE_URL` | Backend API server URL | `http://localhost:8000` |
| `API_TIMEOUT` | Request timeout (ms) | `30000` |
### API Endpoints
All backend API routes are defined in `API_ENDPOINTS`:
- **AUTH**: `/login`, `/register`
- **ASSETS**: `/assets/get`, `/assets/create`, `/assets/claim`, `/assets/assign`
- **AI**: `/ai/proxy`
- **ADMIN**: `/admin/declare-guale`
### Environment Setup
For development, you may need to modify `API_BASE_URL` in the config file to match your backend server address.
## Project Structure ## Project Structure
@@ -56,14 +83,27 @@ src/
│ ├── BiometricModal.tsx │ ├── BiometricModal.tsx
│ ├── Icons.tsx │ ├── Icons.tsx
│ └── VaultDoorAnimation.tsx │ └── VaultDoorAnimation.tsx
├── config/
│ └── index.ts # Centralized configuration
├── context/
│ └── AuthContext.tsx # Authentication state management
├── navigation/ ├── navigation/
── TabNavigator.tsx ── AuthNavigator.tsx # Login/Register navigation
│ └── TabNavigator.tsx # Main app navigation
├── screens/ ├── screens/
│ ├── FlowScreen.tsx │ ├── FlowScreen.tsx # AI chat interface
│ ├── VaultScreen.tsx │ ├── VaultScreen.tsx
│ ├── SentinelScreen.tsx │ ├── SentinelScreen.tsx
│ ├── HeritageScreen.tsx │ ├── HeritageScreen.tsx
── MeScreen.tsx ── MeScreen.tsx
│ ├── LoginScreen.tsx
│ └── RegisterScreen.tsx
├── services/
│ ├── index.ts # Service exports
│ ├── ai.service.ts # AI API integration
│ ├── auth.service.ts # Authentication API
│ ├── assets.service.ts # Asset management API
│ └── admin.service.ts # Admin operations API
├── theme/ ├── theme/
│ └── colors.ts │ └── colors.ts
└── types/ └── types/
@@ -80,6 +120,15 @@ assets/
└── captain-avatar.svg # Avatar placeholder └── captain-avatar.svg # Avatar placeholder
``` ```
## Services
The application uses a modular service architecture for API communication:
- **AuthService**: User authentication (login, register)
- **AIService**: AI conversation proxy with support for text and image input
- **AssetsService**: Digital asset management
- **AdminService**: Administrative operations
## Icons & Branding ## Icons & Branding
The Sentinel brand uses a nautical anchor-and-star logo on a teal (#459E9E) background. The Sentinel brand uses a nautical anchor-and-star logo on a teal (#459E9E) background.
@@ -163,8 +212,44 @@ Sentinel 是一款帮助用户安全管理数字遗产的移动应用程序。
- **框架**: React Native (Expo SDK 52) - **框架**: React Native (Expo SDK 52)
- **语言**: TypeScript - **语言**: TypeScript
- **导航**: React Navigation (底部标签) - **导航**: React Navigation (底部标签)
- **图标**: @expo/vector-icons - **图标**: @expo/vector-icons (Feather, Ionicons, FontAwesome5)
- **样式**: 自定义航海主题配渐变 - **样式**: 自定义航海主题配渐变
- **状态管理**: React Context (AuthContext)
- **存储**: AsyncStorage 用于认证持久化
## 配置说明
应用使用位于 `src/config/index.ts` 的集中配置文件。
### 主要配置项
| 选项 | 说明 | 默认值 |
|------|------|--------|
| `NO_BACKEND_MODE` | 使用模拟数据而非真实后端 | `false` |
| `DEBUG_MODE` | 启用 API 调试日志 | `true` |
| `API_BASE_URL` | 后端 API 服务器地址 | `http://localhost:8000` |
| `API_TIMEOUT` | 请求超时时间(毫秒) | `30000` |
### API 端点
所有后端 API 路由定义在 `API_ENDPOINTS` 中:
- **AUTH**: `/login`, `/register`
- **ASSETS**: `/assets/get`, `/assets/create`, `/assets/claim`, `/assets/assign`
- **AI**: `/ai/proxy`
- **ADMIN**: `/admin/declare-guale`
### 环境配置
开发时,您可能需要修改配置文件中的 `API_BASE_URL` 以匹配您的后端服务器地址。
## 服务层
应用使用模块化的服务架构进行 API 通信:
- **AuthService**: 用户认证(登录、注册)
- **AIService**: AI 对话代理,支持文本和图片输入
- **AssetsService**: 数字资产管理
- **AdminService**: 管理员操作
## 运行项目 ## 运行项目

137
package-lock.json generated
View File

@@ -10,13 +10,16 @@
"dependencies": { "dependencies": {
"@expo/metro-runtime": "~4.0.1", "@expo/metro-runtime": "~4.0.1",
"@expo/vector-icons": "~14.0.4", "@expo/vector-icons": "~14.0.4",
"@react-native-async-storage/async-storage": "^2.2.0",
"@react-navigation/bottom-tabs": "^6.6.1", "@react-navigation/bottom-tabs": "^6.6.1",
"@react-navigation/native": "^6.1.18", "@react-navigation/native": "^6.1.18",
"@react-navigation/native-stack": "^6.11.0",
"expo": "~52.0.0", "expo": "~52.0.0",
"expo-asset": "~11.0.5", "expo-asset": "~11.0.5",
"expo-constants": "~17.0.8", "expo-constants": "~17.0.8",
"expo-font": "~13.0.4", "expo-font": "~13.0.4",
"expo-haptics": "~14.0.0", "expo-haptics": "~14.0.0",
"expo-image-picker": "^17.0.10",
"expo-linear-gradient": "~14.0.2", "expo-linear-gradient": "~14.0.2",
"expo-status-bar": "~2.0.0", "expo-status-bar": "~2.0.0",
"react": "18.3.1", "react": "18.3.1",
@@ -26,6 +29,7 @@
"react-native-reanimated": "~3.16.1", "react-native-reanimated": "~3.16.1",
"react-native-safe-area-context": "4.12.0", "react-native-safe-area-context": "4.12.0",
"react-native-screens": "~4.4.0", "react-native-screens": "~4.4.0",
"react-native-view-shot": "^3.8.0",
"react-native-web": "~0.19.13" "react-native-web": "~0.19.13"
}, },
"devDependencies": { "devDependencies": {
@@ -3245,6 +3249,18 @@
"node": ">=14" "node": ">=14"
} }
}, },
"node_modules/@react-native-async-storage/async-storage": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-2.2.0.tgz",
"integrity": "sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw==",
"license": "MIT",
"dependencies": {
"merge-options": "^3.0.4"
},
"peerDependencies": {
"react-native": "^0.0.0-0 || >=0.65 <1.0"
}
},
"node_modules/@react-native/assets-registry": { "node_modules/@react-native/assets-registry": {
"version": "0.76.9", "version": "0.76.9",
"resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.76.9.tgz", "resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.76.9.tgz",
@@ -3684,6 +3700,23 @@
"react-native": "*" "react-native": "*"
} }
}, },
"node_modules/@react-navigation/native-stack": {
"version": "6.11.0",
"resolved": "https://registry.npmmirror.com/@react-navigation/native-stack/-/native-stack-6.11.0.tgz",
"integrity": "sha512-U5EcUB9Q2NQspCFwYGGNJm0h6wBCOv7T30QjndmvlawLkNt7S7KWbpWyxS9XBHSIKF57RgWjfxuJNTgTstpXxw==",
"license": "MIT",
"dependencies": {
"@react-navigation/elements": "^1.3.31",
"warn-once": "^0.1.0"
},
"peerDependencies": {
"@react-navigation/native": "^6.0.0",
"react": "*",
"react-native": "*",
"react-native-safe-area-context": ">= 3.0.0",
"react-native-screens": ">= 3.0.0"
}
},
"node_modules/@react-navigation/routers": { "node_modules/@react-navigation/routers": {
"version": "6.1.9", "version": "6.1.9",
"resolved": "https://registry.npmjs.org/@react-navigation/routers/-/routers-6.1.9.tgz", "resolved": "https://registry.npmjs.org/@react-navigation/routers/-/routers-6.1.9.tgz",
@@ -4311,6 +4344,15 @@
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/base64-arraybuffer": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/base64-js": { "node_modules/base64-js": {
"version": "1.5.1", "version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@@ -5097,6 +5139,15 @@
"hyphenate-style-name": "^1.0.3" "hyphenate-style-name": "^1.0.3"
} }
}, },
"node_modules/css-line-break": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
"integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
"license": "MIT",
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/csstype": { "node_modules/csstype": {
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -5678,6 +5729,27 @@
"expo": "*" "expo": "*"
} }
}, },
"node_modules/expo-image-loader": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/expo-image-loader/-/expo-image-loader-6.0.0.tgz",
"integrity": "sha512-nKs/xnOGw6ACb4g26xceBD57FKLFkSwEUTDXEDF3Gtcu3MqF3ZIYd3YM+sSb1/z9AKV1dYT7rMSGVNgsveXLIQ==",
"license": "MIT",
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-image-picker": {
"version": "17.0.10",
"resolved": "https://registry.npmjs.org/expo-image-picker/-/expo-image-picker-17.0.10.tgz",
"integrity": "sha512-a2xrowp2trmvXyUWgX3O6Q2rZaa2C59AqivKI7+bm+wLvMfTEbZgldLX4rEJJhM8xtmEDTNU+lzjtObwzBRGaw==",
"license": "MIT",
"dependencies": {
"expo-image-loader": "~6.0.0"
},
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-linear-gradient": { "node_modules/expo-linear-gradient": {
"version": "14.0.2", "version": "14.0.2",
"resolved": "https://registry.npmjs.org/expo-linear-gradient/-/expo-linear-gradient-14.0.2.tgz", "resolved": "https://registry.npmjs.org/expo-linear-gradient/-/expo-linear-gradient-14.0.2.tgz",
@@ -6359,6 +6431,19 @@
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/html2canvas": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
"license": "MIT",
"dependencies": {
"css-line-break": "^2.1.0",
"text-segmentation": "^1.0.3"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/http-errors": { "node_modules/http-errors": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -6668,6 +6753,15 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/is-plain-obj": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz",
"integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/is-plain-object": { "node_modules/is-plain-object": {
"version": "2.0.4", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz",
@@ -7547,6 +7641,18 @@
"integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==", "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/merge-options": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/merge-options/-/merge-options-3.0.4.tgz",
"integrity": "sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ==",
"license": "MIT",
"dependencies": {
"is-plain-obj": "^2.1.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/merge-stream": { "node_modules/merge-stream": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
@@ -9260,6 +9366,19 @@
"react-native": "*" "react-native": "*"
} }
}, },
"node_modules/react-native-view-shot": {
"version": "3.8.0",
"resolved": "https://registry.npmjs.org/react-native-view-shot/-/react-native-view-shot-3.8.0.tgz",
"integrity": "sha512-4cU8SOhMn3YQIrskh+5Q8VvVRxQOu8/s1M9NAL4z5BY1Rm0HXMWkQJ4N0XsZ42+Yca+y86ISF3LC5qdLPvPuiA==",
"license": "MIT",
"dependencies": {
"html2canvas": "^1.4.1"
},
"peerDependencies": {
"react": "*",
"react-native": "*"
}
},
"node_modules/react-native-web": { "node_modules/react-native-web": {
"version": "0.19.13", "version": "0.19.13",
"resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.19.13.tgz", "resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.19.13.tgz",
@@ -10509,6 +10628,15 @@
"url": "https://github.com/sponsors/isaacs" "url": "https://github.com/sponsors/isaacs"
} }
}, },
"node_modules/text-segmentation": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
"integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
"license": "MIT",
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/thenify": { "node_modules/thenify": {
"version": "3.3.1", "version": "3.3.1",
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
@@ -10796,6 +10924,15 @@
"node": ">= 0.4.0" "node": ">= 0.4.0"
} }
}, },
"node_modules/utrie": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
"integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
"license": "MIT",
"dependencies": {
"base64-arraybuffer": "^1.0.2"
}
},
"node_modules/uuid": { "node_modules/uuid": {
"version": "8.3.2", "version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",

View File

@@ -11,19 +11,23 @@
"dependencies": { "dependencies": {
"@expo/metro-runtime": "~4.0.1", "@expo/metro-runtime": "~4.0.1",
"@expo/vector-icons": "~14.0.4", "@expo/vector-icons": "~14.0.4",
"@react-native-async-storage/async-storage": "^2.2.0",
"@react-navigation/bottom-tabs": "^6.6.1", "@react-navigation/bottom-tabs": "^6.6.1",
"@react-navigation/native": "^6.1.18", "@react-navigation/native": "^6.1.18",
"@react-navigation/native-stack": "^6.11.0",
"expo": "~52.0.0", "expo": "~52.0.0",
"expo-asset": "~11.0.5", "expo-asset": "~11.0.5",
"expo-constants": "~17.0.8", "expo-constants": "~17.0.8",
"expo-font": "~13.0.4", "expo-font": "~13.0.4",
"expo-haptics": "~14.0.0", "expo-haptics": "~14.0.0",
"expo-image-picker": "^17.0.10",
"expo-linear-gradient": "~14.0.2", "expo-linear-gradient": "~14.0.2",
"expo-status-bar": "~2.0.0", "expo-status-bar": "~2.0.0",
"react": "18.3.1", "react": "18.3.1",
"react-dom": "18.3.1", "react-dom": "18.3.1",
"react-native": "^0.76.9", "react-native": "^0.76.9",
"react-native-gesture-handler": "~2.20.2", "react-native-gesture-handler": "~2.20.2",
"react-native-view-shot": "^3.8.0",
"react-native-reanimated": "~3.16.1", "react-native-reanimated": "~3.16.1",
"react-native-safe-area-context": "4.12.0", "react-native-safe-area-context": "4.12.0",
"react-native-screens": "~4.4.0", "react-native-screens": "~4.4.0",

146
src/config/index.ts Normal file
View File

@@ -0,0 +1,146 @@
/**
* Application Configuration
*
* This file contains all configuration settings for the frontend application.
* Centralized configuration makes it easier to manage environment-specific settings.
*/
// =============================================================================
// Environment Configuration
// =============================================================================
/**
* Set to true to use mock data instead of real backend calls.
* Useful for development and testing without a running backend.
*/
export const NO_BACKEND_MODE = false;
/**
* Enable debug logging for API calls
*/
export const DEBUG_MODE = true;
// =============================================================================
// API Configuration
// =============================================================================
/**
* Base URL for the backend API server
*/
export const API_BASE_URL = 'http://localhost:8000';
/**
* API request timeout in milliseconds
*/
export const API_TIMEOUT = 30000;
/**
* API Endpoints
* All backend API routes are defined here for easy reference and maintenance.
*/
export const API_ENDPOINTS = {
// Authentication
AUTH: {
LOGIN: '/login',
REGISTER: '/register',
},
// Assets Management
ASSETS: {
GET: '/assets/get',
CREATE: '/assets/create',
CLAIM: '/assets/claim',
ASSIGN: '/assets/assign',
},
// AI Services
AI: {
PROXY: '/ai/proxy',
},
// Admin Operations
ADMIN: {
DECLARE_GUALE: '/admin/declare-guale',
},
} as const;
// =============================================================================
// Helper Functions
// =============================================================================
/**
* Build full API URL from endpoint
* @param endpoint - API endpoint path (e.g., '/login')
* @returns Full URL (e.g., 'http://192.168.56.103:8000/login')
*/
export function buildApiUrl(endpoint: string): string {
return `${API_BASE_URL}${endpoint}`;
}
/**
* Get default headers for API requests
* @param token - Optional JWT token for authenticated requests
* @returns Headers object
*/
export function getApiHeaders(token?: string): Record<string, string> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
return headers;
}
/**
* Log API debug information
* Only logs when DEBUG_MODE is enabled
*/
export function logApiDebug(label: string, data: unknown): void {
if (DEBUG_MODE) {
console.log(`[API Debug] ${label}:`, data);
}
}
// =============================================================================
// Mock User Configuration (for NO_BACKEND_MODE)
// =============================================================================
export const MOCK_CONFIG = {
USER: {
id: 999,
username: 'MockCaptain',
public_key: 'mock_public_key',
is_admin: true,
guale: false,
tier: 'premium',
tier_expires_at: '2026-12-31T23:59:59Z',
last_active_at: new Date().toISOString(),
},
ACCESS_TOKEN: 'mock_access_token',
RESPONSE_DELAY: 200, // milliseconds
} as const;
// =============================================================================
// AI Service Configuration
// =============================================================================
export const AI_CONFIG = {
/**
* Default system prompt for AI conversations
*/
DEFAULT_SYSTEM_PROMPT: 'You are a helpful journal assistant. Help the user reflect on their thoughts and feelings.',
/**
* Mock response delay in milliseconds (for NO_BACKEND_MODE)
*/
MOCK_RESPONSE_DELAY: 500,
} as const;
// =============================================================================
// Export Type Definitions
// =============================================================================
export type ApiEndpoint = typeof API_ENDPOINTS;

175
src/context/AuthContext.tsx Normal file
View File

@@ -0,0 +1,175 @@
/**
* AuthContext - Authentication State Management
*
* Provides authentication state and methods throughout the app.
* Uses AsyncStorage for persistent login state.
*/
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { User, LoginRequest, RegisterRequest } from '../types';
import { authService } from '../services/auth.service';
// =============================================================================
// Type Definitions
// =============================================================================
interface AuthContextType {
user: User | null;
token: string | null;
isLoading: boolean;
isInitializing: boolean;
signIn: (credentials: LoginRequest) => Promise<void>;
signUp: (data: RegisterRequest) => Promise<void>;
signOut: () => void;
}
// Storage keys
const STORAGE_KEYS = {
TOKEN: '@auth_token',
USER: '@auth_user',
};
// =============================================================================
// Context
// =============================================================================
const AuthContext = createContext<AuthContextType | undefined>(undefined);
// =============================================================================
// Provider Component
// =============================================================================
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [token, setToken] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isInitializing, setIsInitializing] = useState(true);
// Load saved auth state on app start
useEffect(() => {
loadStoredAuth();
}, []);
/**
* Load stored authentication from AsyncStorage
*/
const loadStoredAuth = async () => {
try {
const [storedToken, storedUser] = await Promise.all([
AsyncStorage.getItem(STORAGE_KEYS.TOKEN),
AsyncStorage.getItem(STORAGE_KEYS.USER),
]);
if (storedToken && storedUser) {
setToken(storedToken);
setUser(JSON.parse(storedUser));
console.log('[Auth] Restored session for user:', JSON.parse(storedUser).username);
}
} catch (error) {
console.error('[Auth] Failed to load stored auth:', error);
} finally {
setIsInitializing(false);
}
};
/**
* Save authentication to AsyncStorage
*/
const saveAuth = async (authToken: string, authUser: User) => {
try {
await Promise.all([
AsyncStorage.setItem(STORAGE_KEYS.TOKEN, authToken),
AsyncStorage.setItem(STORAGE_KEYS.USER, JSON.stringify(authUser)),
]);
console.log('[Auth] Session saved for user:', authUser.username);
} catch (error) {
console.error('[Auth] Failed to save auth:', error);
}
};
/**
* Clear authentication from AsyncStorage
*/
const clearAuth = async () => {
try {
await Promise.all([
AsyncStorage.removeItem(STORAGE_KEYS.TOKEN),
AsyncStorage.removeItem(STORAGE_KEYS.USER),
]);
console.log('[Auth] Session cleared');
} catch (error) {
console.error('[Auth] Failed to clear auth:', error);
}
};
/**
* Sign in with username and password
*/
const signIn = async (credentials: LoginRequest) => {
setIsLoading(true);
try {
const response = await authService.login(credentials);
setToken(response.access_token);
setUser(response.user);
await saveAuth(response.access_token, response.user);
} catch (error) {
throw error;
} finally {
setIsLoading(false);
}
};
/**
* Sign up and automatically sign in
*/
const signUp = async (data: RegisterRequest) => {
setIsLoading(true);
try {
await authService.register(data);
// After successful registration, sign in automatically
await signIn({ username: data.username, password: data.password });
} catch (error) {
throw error;
} finally {
setIsLoading(false);
}
};
/**
* Sign out and clear stored auth
*/
const signOut = () => {
setUser(null);
setToken(null);
clearAuth();
};
return (
<AuthContext.Provider
value={{
user,
token,
isLoading,
isInitializing,
signIn,
signUp,
signOut
}}
>
{children}
</AuthContext.Provider>
);
}
// =============================================================================
// Hook
// =============================================================================
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}

View File

@@ -0,0 +1,20 @@
import React from 'react';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import LoginScreen from '../screens/LoginScreen';
import RegisterScreen from '../screens/RegisterScreen';
const Stack = createNativeStackNavigator();
export default function AuthNavigator() {
return (
<Stack.Navigator
screenOptions={{
headerShown: false,
animation: 'slide_from_right',
}}
>
<Stack.Screen name="Login" component={LoginScreen} />
<Stack.Screen name="Register" component={RegisterScreen} />
</Stack.Navigator>
);
}

View File

@@ -7,7 +7,7 @@ import { colors, borderRadius, typography } from '../theme/colors';
// Screens // Screens
import FlowScreen from '../screens/FlowScreen'; import FlowScreen from '../screens/FlowScreen';
import SentinelScreen from '../screens/SentinelScreen'; import SentinelScreen from '../screens/SentinelScreen';
import HeritageScreen from '../screens/HeritageScreen'; // import HeritageScreen from '../screens/HeritageScreen'; // Heritage functionality moved to Me and Sentinel
import MeScreen from '../screens/MeScreen'; import MeScreen from '../screens/MeScreen';
const Tab = createBottomTabNavigator(); const Tab = createBottomTabNavigator();
@@ -104,6 +104,7 @@ export default function TabNavigator() {
tabBarStyle: styles.tabBarDark, tabBarStyle: styles.tabBarDark,
}} }}
/> />
{/* Heritage tab commented out - functionality moved to Me (Fleet Legacy) and Sentinel (Shadow Vault)
<Tab.Screen <Tab.Screen
name="Heritage" name="Heritage"
component={HeritageScreen} component={HeritageScreen}
@@ -118,6 +119,7 @@ export default function TabNavigator() {
), ),
}} }}
/> />
*/}
<Tab.Screen <Tab.Screen
name="Me" name="Me"
component={MeScreen} component={MeScreen}

File diff suppressed because it is too large Load Diff

256
src/screens/LoginScreen.tsx Normal file
View File

@@ -0,0 +1,256 @@
import React, { useState } from 'react';
import {
View,
Text,
StyleSheet,
TextInput,
TouchableOpacity,
KeyboardAvoidingView,
Platform,
SafeAreaView,
Alert,
ActivityIndicator,
} from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import { Feather, MaterialCommunityIcons } from '@expo/vector-icons';
import { colors, spacing, borderRadius, typography, shadows } from '../theme/colors';
import { useAuth } from '../context/AuthContext';
export default function LoginScreen({ navigation }: any) {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const { signIn, isLoading } = useAuth();
const handleLogin = async () => {
setError(null);
if (!email || !password) {
setError('Please enter both username and password.');
return;
}
try {
await signIn({ username: email, password });
} catch (err: any) {
setError('Login failed. Please check your credentials.');
}
};
return (
<View style={styles.container}>
<LinearGradient
colors={[colors.sentinel.backgroundGradientStart, colors.sentinel.backgroundGradientEnd]}
style={styles.gradient}
>
<SafeAreaView style={styles.safeArea}>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.content}
>
<View style={styles.header}>
<View style={styles.logoContainer}>
<MaterialCommunityIcons name="anchor" size={48} color={colors.sentinel.primary} />
</View>
<Text style={styles.title}>Welcome Aboard!</Text>
<Text style={styles.subtitle}>Sign in to access your sanctuaryy</Text>
{error && (
<View style={styles.errorContainer}>
<MaterialCommunityIcons name="alert-circle-outline" size={20} color={colors.sentinel.statusCritical} />
<Text style={styles.errorText}>{error}</Text>
</View>
)}
</View>
<View style={styles.form}>
<View style={styles.inputContainer}>
<Feather name="mail" size={20} color={colors.sentinel.textSecondary} style={styles.inputIcon} />
<TextInput
style={styles.input}
placeholder="Username"
placeholderTextColor={colors.sentinel.textSecondary}
value={email}
onChangeText={setEmail}
autoCapitalize="none"
/>
</View>
<View style={styles.inputContainer}>
<Feather name="lock" size={20} color={colors.sentinel.textSecondary} style={styles.inputIcon} />
<TextInput
style={styles.input}
placeholder="Password"
placeholderTextColor={colors.sentinel.textSecondary}
value={password}
onChangeText={setPassword}
secureTextEntry
/>
</View>
<TouchableOpacity style={styles.forgotButton}>
<Text style={styles.forgotText}>Forgot Password?</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.loginButton}
activeOpacity={0.8}
onPress={handleLogin}
disabled={isLoading}
>
{isLoading ? (
<ActivityIndicator color={colors.white} />
) : (
<Text style={styles.loginButtonText}>Login</Text>
)}
</TouchableOpacity>
<View style={styles.divider}>
<View style={styles.dividerLine} />
<Text style={styles.dividerText}>OR</Text>
<View style={styles.dividerLine} />
</View>
<TouchableOpacity
style={styles.registerLink}
onPress={() => navigation.navigate('Register')}
>
<Text style={styles.registerText}>
Don't have an account? <Text style={styles.registerHighlight}>Join the Fleet</Text>
</Text>
</TouchableOpacity>
</View>
</KeyboardAvoidingView>
</SafeAreaView>
</LinearGradient>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
gradient: {
flex: 1,
},
safeArea: {
flex: 1,
},
content: {
flex: 1,
paddingHorizontal: spacing.lg,
justifyContent: 'center',
},
header: {
alignItems: 'center',
marginBottom: spacing.xxl,
},
logoContainer: {
width: 80,
height: 80,
borderRadius: borderRadius.full,
backgroundColor: 'rgba(255, 255, 255, 0.1)',
alignItems: 'center',
justifyContent: 'center',
marginBottom: spacing.lg,
borderWidth: 1,
borderColor: 'rgba(184, 224, 229, 0.2)',
},
title: {
fontSize: typography.fontSize.xxl,
fontWeight: 'bold',
color: colors.sentinel.text,
marginBottom: spacing.xs,
letterSpacing: 0.5,
},
subtitle: {
fontSize: typography.fontSize.base,
color: colors.sentinel.textSecondary,
marginBottom: spacing.md,
},
errorContainer: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(255, 68, 68, 0.1)',
padding: spacing.sm,
borderRadius: borderRadius.md,
marginTop: spacing.sm,
borderWidth: 1,
borderColor: 'rgba(255, 68, 68, 0.2)',
},
errorText: {
color: colors.sentinel.statusCritical,
fontSize: typography.fontSize.sm,
marginLeft: spacing.xs,
fontWeight: '500',
},
form: {
width: '100%',
},
inputContainer: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(255, 255, 255, 0.05)',
borderRadius: borderRadius.lg,
borderWidth: 1,
borderColor: 'rgba(184, 224, 229, 0.1)',
marginBottom: spacing.base,
height: 56,
paddingHorizontal: spacing.base,
},
inputIcon: {
marginRight: spacing.md,
},
input: {
flex: 1,
color: colors.sentinel.text,
fontSize: typography.fontSize.base,
},
forgotButton: {
alignSelf: 'flex-end',
marginBottom: spacing.lg,
},
forgotText: {
color: colors.sentinel.primary,
fontSize: typography.fontSize.sm,
},
loginButton: {
backgroundColor: colors.nautical.teal,
height: 56,
borderRadius: borderRadius.lg,
alignItems: 'center',
justifyContent: 'center',
...shadows.glow,
},
loginButtonText: {
color: colors.white,
fontSize: typography.fontSize.md,
fontWeight: 'bold',
letterSpacing: 0.5,
},
divider: {
flexDirection: 'row',
alignItems: 'center',
marginVertical: spacing.xl,
},
dividerLine: {
flex: 1,
height: 1,
backgroundColor: 'rgba(184, 224, 229, 0.1)',
},
dividerText: {
color: colors.sentinel.textSecondary,
paddingHorizontal: spacing.md,
fontSize: typography.fontSize.sm,
},
registerLink: {
alignItems: 'center',
},
registerText: {
color: colors.sentinel.textSecondary,
fontSize: typography.fontSize.base,
},
registerHighlight: {
color: colors.sentinel.primary,
fontWeight: 'bold',
},
});

View File

@@ -9,10 +9,67 @@ import {
SafeAreaView, SafeAreaView,
Linking, Linking,
Alert, Alert,
TextInput,
} from 'react-native'; } from 'react-native';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import { Ionicons, Feather, MaterialCommunityIcons, FontAwesome5 } from '@expo/vector-icons'; import { Ionicons, Feather, MaterialCommunityIcons, FontAwesome5 } from '@expo/vector-icons';
import { colors, typography, spacing, borderRadius, shadows } from '../theme/colors'; import { colors, typography, spacing, borderRadius, shadows } from '../theme/colors';
import { useAuth } from '../context/AuthContext';
import { Heir, HeirStatus, PaymentStrategy } from '../types';
import HeritageScreen from './HeritageScreen';
// Mock heirs data
const initialHeirs: Heir[] = [
{
id: '1',
name: 'John Smith',
email: 'john.smith@email.com',
phone: '+1 415 555 0132',
status: 'confirmed',
releaseLevel: 3,
releaseOrder: 1,
paymentStrategy: 'prepaid',
},
{
id: '2',
name: 'Jane Doe',
email: 'jane.doe@email.com',
phone: '+1 212 555 0184',
status: 'confirmed',
releaseLevel: 2,
releaseOrder: 2,
paymentStrategy: 'pay_on_access',
},
{
id: '3',
name: 'Alice Johnson',
email: 'alice.j@email.com',
phone: '+1 646 555 0149',
status: 'invited',
releaseLevel: 1,
releaseOrder: 3,
paymentStrategy: 'pay_on_access',
},
];
// Release level descriptions
const releaseLevelConfig: Record<number, { label: string; description: string; icon: string }> = {
1: {
label: 'Journal Summaries',
description: 'Captain\'s log excerpts and emotional records',
icon: 'book-open',
},
2: {
label: 'Treasure Map',
description: 'Asset inventory and metadata',
icon: 'map',
},
3: {
label: 'Full Inheritance',
description: 'Complete encrypted treasure chest',
icon: 'key',
},
};
// Sentinel Protocol Status // Sentinel Protocol Status
const protocolStatus = { const protocolStatus = {
@@ -160,6 +217,19 @@ export default function MeScreen() {
const [showSanctumModal, setShowSanctumModal] = useState(false); const [showSanctumModal, setShowSanctumModal] = useState(false);
const [showCaptainFull, setShowCaptainFull] = useState(false); const [showCaptainFull, setShowCaptainFull] = useState(false);
const [showTriggerModal, setShowTriggerModal] = useState(false); const [showTriggerModal, setShowTriggerModal] = useState(false);
const [showHeritageModal, setShowHeritageModal] = useState(false);
const [showThemeModal, setShowThemeModal] = useState(false);
const [isDarkMode, setIsDarkMode] = useState(false);
// Heritage / Fleet Legacy states
const [heirs, setHeirs] = useState<Heir[]>(initialHeirs);
const [showAddHeirModal, setShowAddHeirModal] = useState(false);
const [showHeirDetailModal, setShowHeirDetailModal] = useState(false);
const [selectedHeir, setSelectedHeir] = useState<Heir | null>(null);
const [newHeirName, setNewHeirName] = useState('');
const [newHeirEmail, setNewHeirEmail] = useState('');
const [newHeirLevel, setNewHeirLevel] = useState(1);
const [newHeirPayment, setNewHeirPayment] = useState<PaymentStrategy>('pay_on_access');
const [tideLevel, setTideLevel] = useState<'low' | 'high' | 'red'>('low'); const [tideLevel, setTideLevel] = useState<'low' | 'high' | 'red'>('low');
const [tideChannels, setTideChannels] = useState({ const [tideChannels, setTideChannels] = useState({
push: true, push: true,
@@ -179,21 +249,60 @@ export default function MeScreen() {
const [triggerGraceDays, setTriggerGraceDays] = useState(15); const [triggerGraceDays, setTriggerGraceDays] = useState(15);
const [triggerSource, setTriggerSource] = useState<'dual' | 'subscription' | 'activity'>('dual'); const [triggerSource, setTriggerSource] = useState<'dual' | 'subscription' | 'activity'>('dual');
const [triggerKillSwitch, setTriggerKillSwitch] = useState(true); const [triggerKillSwitch, setTriggerKillSwitch] = useState(true);
const { user, signOut } = useAuth();
const handleOpenLink = (url: string) => { const handleOpenLink = (url: string) => {
Linking.openURL(url).catch(() => { }); Linking.openURL(url).catch(() => { });
}; };
// Heritage / Fleet Legacy functions
const handleAddHeir = () => {
if (!newHeirName.trim() || !newHeirEmail.trim()) return;
const newHeir: Heir = {
id: Date.now().toString(),
name: newHeirName,
email: newHeirEmail,
status: 'invited',
releaseLevel: newHeirLevel,
releaseOrder: heirs.length + 1,
paymentStrategy: newHeirPayment,
};
setHeirs([...heirs, newHeir]);
resetAddHeirForm();
setShowAddHeirModal(false);
};
const resetAddHeirForm = () => {
setNewHeirName('');
setNewHeirEmail('');
setNewHeirLevel(1);
setNewHeirPayment('pay_on_access');
};
const handleHeirPress = (heir: Heir) => {
setSelectedHeir(heir);
setShowHeirDetailModal(true);
};
const getHeirStatusBadge = (status: HeirStatus) => {
if (status === 'confirmed') {
return { text: 'Aboard', color: '#6BBF8A', icon: 'checkmark-circle' };
}
return { text: 'Invited', color: colors.nautical.gold, icon: 'time-outline' };
};
const handleAbandonIsland = () => { const handleAbandonIsland = () => {
Alert.alert( Alert.alert(
'Abandon Island', 'Sign Out',
'Are you sure you want to delete your account? This action is irreversible. All your data, including vault contents, will be permanently destroyed.', 'Are you sure you want to sign out?',
[ [
{ text: 'Cancel', style: 'cancel' }, { text: 'Cancel', style: 'cancel' },
{ {
text: 'Delete Account', text: 'Sign Out',
style: 'destructive', style: 'destructive',
onPress: () => Alert.alert('Account Deletion', 'Please contact support to proceed with account deletion.') onPress: signOut
}, },
] ]
); );
@@ -211,14 +320,6 @@ export default function MeScreen() {
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
contentContainerStyle={styles.scrollContent} contentContainerStyle={styles.scrollContent}
> >
{/* Header with Settings */}
<View style={styles.headerRow}>
<View style={styles.headerSpacer} />
<TouchableOpacity style={styles.settingsButton}>
<Ionicons name="moon-outline" size={20} color={colors.me.primary} />
</TouchableOpacity>
</View>
{/* Profile Card */} {/* Profile Card */}
<TouchableOpacity <TouchableOpacity
style={styles.profileCard} style={styles.profileCard}
@@ -235,11 +336,11 @@ export default function MeScreen() {
</View> </View>
</View> </View>
<View style={styles.profileInfo}> <View style={styles.profileInfo}>
<Text style={styles.profileName}>Captain</Text> <Text style={styles.profileName}>{user?.username || 'Captain'}</Text>
<Text style={styles.profileTitle}>MASTER OF THE SANCTUM</Text> <Text style={styles.profileTitle}>MASTER OF THE SANCTUM</Text>
<View style={styles.profileBadge}> <View style={styles.profileBadge}>
<MaterialCommunityIcons name="crown" size={12} color={colors.nautical.gold} /> <MaterialCommunityIcons name="crown" size={12} color={colors.nautical.gold} />
<Text style={styles.profileBadgeText}>Pro Member</Text> <Text style={styles.profileBadgeText}>{user?.tier || 'Pro Member'}</Text>
</View> </View>
</View> </View>
</View> </View>
@@ -309,15 +410,23 @@ export default function MeScreen() {
))} ))}
</View> </View>
{/* Abandon Island Button */} {/* Fleet Legacy - Single Entry Point */}
<Text style={styles.sectionTitle}>FLEET LEGACY</Text>
<View style={styles.menuCard}>
<TouchableOpacity <TouchableOpacity
style={styles.abandonButton} style={styles.menuItem}
onPress={handleAbandonIsland} onPress={() => setShowHeritageModal(true)}
activeOpacity={0.8}
> >
<Feather name="log-out" size={18} color={colors.nautical.coral} /> <View style={[styles.menuIconContainer, { backgroundColor: `${colors.nautical.teal}20` }]}>
<Text style={styles.abandonButtonText}>ABANDON ISLAND</Text> <MaterialCommunityIcons name="compass-outline" size={22} color={colors.nautical.teal} />
</View>
<View style={styles.menuContent}>
<Text style={styles.menuTitle}>Manage Heirs</Text>
<Text style={styles.menuSubtitle}>{heirs.length} trustees configured</Text>
</View>
<Feather name="chevron-right" size={18} color={colors.me.textSecondary} />
</TouchableOpacity> </TouchableOpacity>
</View>
{/* Settings Menu */} {/* Settings Menu */}
<Text style={styles.sectionTitle}>SETTINGS</Text> <Text style={styles.sectionTitle}>SETTINGS</Text>
@@ -333,6 +442,9 @@ export default function MeScreen() {
if (item.id === 'trigger') { if (item.id === 'trigger') {
setShowTriggerModal(true); setShowTriggerModal(true);
} }
if (item.id === 'visual') {
setShowThemeModal(true);
}
}} }}
> >
<View style={[styles.menuIconContainer, { backgroundColor: `${item.color}15` }]}> <View style={[styles.menuIconContainer, { backgroundColor: `${item.color}15` }]}>
@@ -347,44 +459,83 @@ export default function MeScreen() {
))} ))}
</View> </View>
{/* About Section */} {/* About Section - Vertical List */}
<Text style={styles.sectionTitle}>ABOUT</Text> <Text style={styles.sectionTitle}>ABOUT</Text>
<View style={styles.aboutGrid}> <View style={styles.menuCard}>
{protocolExplainers.slice(0, 2).map((item) => ( {protocolExplainers.map((item, index) => (
<TouchableOpacity <TouchableOpacity
key={item.id} key={item.id}
style={styles.aboutCard} style={[
styles.menuItem,
index < protocolExplainers.length - 1 && styles.menuItemBorder
]}
onPress={() => setSelectedExplainer(item)} onPress={() => setSelectedExplainer(item)}
> >
<MaterialCommunityIcons name={item.icon as any} size={24} color={colors.me.primary} /> <View style={[styles.menuIconContainer, { backgroundColor: `${colors.me.primary}15` }]}>
<Text style={styles.aboutTitle}>{item.title}</Text> <MaterialCommunityIcons name={item.icon as any} size={20} color={colors.me.primary} />
<Feather name="arrow-up-right" size={14} color={colors.me.textSecondary} /> </View>
<View style={styles.menuContent}>
<Text style={styles.menuTitle}>{item.title}</Text>
</View>
<Feather name="chevron-right" size={18} color={colors.me.textSecondary} />
</TouchableOpacity> </TouchableOpacity>
))} ))}
<TouchableOpacity
style={[styles.menuItem, styles.menuItemBorder]}
onPress={() => handleOpenLink('https://github.com/sentinel')}
>
<View style={[styles.menuIconContainer, { backgroundColor: `${colors.nautical.navy}15` }]}>
<Ionicons name="logo-github" size={20} color={colors.nautical.navy} />
</View> </View>
<View style={styles.menuContent}>
<Text style={styles.menuTitle}>GitHub</Text>
<Text style={styles.menuSubtitle}>View source code</Text>
</View>
<Feather name="external-link" size={16} color={colors.me.textSecondary} />
</TouchableOpacity>
<TouchableOpacity
style={[styles.menuItem, styles.menuItemBorder]}
onPress={() => handleOpenLink('https://sentinel.app/privacy')}
>
<View style={[styles.menuIconContainer, { backgroundColor: `${colors.nautical.sage}15` }]}>
<Ionicons name="shield-checkmark-outline" size={20} color={colors.nautical.sage} />
</View>
<View style={styles.menuContent}>
<Text style={styles.menuTitle}>Privacy Policy</Text>
</View>
<Feather name="external-link" size={16} color={colors.me.textSecondary} />
</TouchableOpacity>
<TouchableOpacity
style={styles.menuItem}
onPress={() => handleOpenLink('https://sentinel.app/terms')}
>
<View style={[styles.menuIconContainer, { backgroundColor: `${colors.nautical.sage}15` }]}>
<Ionicons name="document-text-outline" size={20} color={colors.nautical.sage} />
</View>
<View style={styles.menuContent}>
<Text style={styles.menuTitle}>Terms of Service</Text>
</View>
<Feather name="external-link" size={16} color={colors.me.textSecondary} />
</TouchableOpacity>
</View>
{/* Sign Out Button */}
<TouchableOpacity
style={styles.abandonButton}
onPress={handleAbandonIsland}
activeOpacity={0.8}
>
<Feather name="log-out" size={18} color={colors.nautical.coral} />
<Text style={styles.abandonButtonText}>SIGN OUT</Text>
</TouchableOpacity>
{/* Footer */} {/* Footer */}
<View style={styles.footer}> <View style={styles.footer}>
<Text style={styles.footerVersion}>A E T E R N A N A U T I C A V 2 . 0</Text> {/* <Text style={styles.footerVersion}>A E T E R N A N A U T I C A V 2 . 0</Text> */}
<Text style={styles.footerTagline}> <Text style={styles.footerTagline}>
The sea claims what is forgotten, but the Sanctuary keeps what is loved. The sea claims what is forgotten, but the Sanctuary keeps what is loved.
</Text> </Text>
</View> </View>
{/* Footer Links */}
<View style={styles.footerLinks}>
<TouchableOpacity onPress={() => handleOpenLink('https://github.com/sentinel')}>
<Text style={styles.footerLink}>GitHub</Text>
</TouchableOpacity>
<Text style={styles.footerDot}>·</Text>
<TouchableOpacity onPress={() => handleOpenLink('https://sentinel.app/privacy')}>
<Text style={styles.footerLink}>Privacy</Text>
</TouchableOpacity>
<Text style={styles.footerDot}>·</Text>
<TouchableOpacity onPress={() => handleOpenLink('https://sentinel.app/terms')}>
<Text style={styles.footerLink}>Terms</Text>
</TouchableOpacity>
</View>
</ScrollView> </ScrollView>
</SafeAreaView> </SafeAreaView>
</LinearGradient> </LinearGradient>
@@ -425,6 +576,114 @@ export default function MeScreen() {
</View> </View>
</Modal> </Modal>
{/* Heritage Modal - Full HeritageScreen */}
<Modal
visible={showHeritageModal}
animationType="slide"
onRequestClose={() => setShowHeritageModal(false)}
>
<View style={styles.heritageModalContainer}>
<HeritageScreen />
<TouchableOpacity
style={styles.heritageCloseButton}
onPress={() => setShowHeritageModal(false)}
activeOpacity={0.85}
>
<Ionicons name="close" size={20} color={colors.nautical.cream} />
</TouchableOpacity>
</View>
</Modal>
{/* Theme Settings Modal */}
<Modal
visible={showThemeModal}
animationType="fade"
transparent
onRequestClose={() => setShowThemeModal(false)}
>
<View style={styles.spiritOverlay}>
<View style={styles.spiritModal}>
<View style={styles.spiritHeader}>
<View style={styles.spiritIcon}>
<MaterialCommunityIcons name="palette-outline" size={24} color={colors.me.primary} />
</View>
<Text style={styles.spiritTitle}>Visual Preferences</Text>
</View>
<ScrollView style={styles.spiritScroll} showsVerticalScrollIndicator={false}>
<View style={styles.sanctumSection}>
<Text style={styles.tideLabel}>APPEARANCE</Text>
<TouchableOpacity
style={styles.sanctumToggleRow}
onPress={() => setIsDarkMode(!isDarkMode)}
activeOpacity={0.85}
>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: spacing.sm }}>
<Ionicons
name={isDarkMode ? 'moon' : 'sunny'}
size={18}
color={colors.me.primary}
/>
<Text style={styles.sanctumText}>Dark Mode</Text>
</View>
<View style={[styles.sanctumToggle, isDarkMode && styles.sanctumToggleOn]}>
<Text style={styles.sanctumToggleText}>{isDarkMode ? 'ON' : 'OFF'}</Text>
</View>
</TouchableOpacity>
</View>
<View style={styles.sanctumSection}>
<Text style={styles.tideLabel}>CARD STYLE</Text>
<View style={styles.tideRow}>
{['Minimal', 'Standard', 'Rich'].map((style) => (
<TouchableOpacity
key={style}
style={[styles.tideButton, style === 'Standard' && styles.tideButtonActive]}
>
<Text style={[styles.tideButtonText, style === 'Standard' && styles.tideButtonTextActive]}>
{style}
</Text>
</TouchableOpacity>
))}
</View>
</View>
<View style={styles.sanctumSection}>
<Text style={styles.tideLabel}>FONT SIZE</Text>
<View style={styles.tideRow}>
{['Small', 'Medium', 'Large'].map((size) => (
<TouchableOpacity
key={size}
style={[styles.tideButton, size === 'Medium' && styles.tideButtonActive]}
>
<Text style={[styles.tideButtonText, size === 'Medium' && styles.tideButtonTextActive]}>
{size}
</Text>
</TouchableOpacity>
))}
</View>
</View>
</ScrollView>
<View style={styles.tideModalButtons}>
<TouchableOpacity
style={styles.confirmPulseButton}
activeOpacity={0.85}
onPress={() => setShowThemeModal(false)}
>
<Ionicons name="checkmark-circle" size={18} color={colors.nautical.teal} />
<Text style={styles.confirmPulseText}>Save</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.confirmPulseButton}
activeOpacity={0.85}
onPress={() => setShowThemeModal(false)}
>
<Ionicons name="close-circle" size={18} color={colors.nautical.teal} />
<Text style={styles.confirmPulseText}>Close</Text>
</TouchableOpacity>
</View>
</View>
</View>
</Modal>
{/* Spirit Keys Modal */} {/* Spirit Keys Modal */}
<Modal <Modal
@@ -856,6 +1115,170 @@ export default function MeScreen() {
</View> </View>
</Modal> </Modal>
{/* Add Heir Modal */}
<Modal
visible={showAddHeirModal}
animationType="slide"
transparent
onRequestClose={() => setShowAddHeirModal(false)}
>
<View style={styles.modalOverlay}>
<View style={styles.modalContent}>
<View style={styles.modalHandle} />
<View style={styles.modalHeader}>
<View style={styles.modalIconContainer}>
<FontAwesome5 name="ship" size={20} color={colors.me.primary} />
</View>
<Text style={styles.modalTitle}>Add to Fleet</Text>
</View>
<Text style={styles.inputLabel}>NAME *</Text>
<TextInput
style={styles.heirInput}
placeholder="Trustee name"
placeholderTextColor={colors.me.textSecondary}
value={newHeirName}
onChangeText={setNewHeirName}
/>
<Text style={styles.inputLabel}>EMAIL *</Text>
<TextInput
style={styles.heirInput}
placeholder="For invitations and notifications"
placeholderTextColor={colors.me.textSecondary}
value={newHeirEmail}
onChangeText={setNewHeirEmail}
keyboardType="email-address"
autoCapitalize="none"
/>
<Text style={styles.inputLabel}>INHERITANCE TIER</Text>
<View style={styles.tideRow}>
{[1, 2, 3].map((level) => (
<TouchableOpacity
key={level}
style={[styles.tideButton, newHeirLevel === level && styles.tideButtonActive]}
onPress={() => setNewHeirLevel(level)}
>
<Text style={[styles.tideButtonText, newHeirLevel === level && styles.tideButtonTextActive]}>
Tier {level}
</Text>
</TouchableOpacity>
))}
</View>
<Text style={styles.inputLabel}>PAYMENT STRATEGY</Text>
<View style={styles.tideRow}>
<TouchableOpacity
style={[styles.tideButton, newHeirPayment === 'prepaid' && styles.tideButtonActive]}
onPress={() => setNewHeirPayment('prepaid')}
>
<Text style={[styles.tideButtonText, newHeirPayment === 'prepaid' && styles.tideButtonTextActive]}>
Prepaid
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.tideButton, newHeirPayment === 'pay_on_access' && styles.tideButtonActive]}
onPress={() => setNewHeirPayment('pay_on_access')}
>
<Text style={[styles.tideButtonText, newHeirPayment === 'pay_on_access' && styles.tideButtonTextActive]}>
Pay on Access
</Text>
</TouchableOpacity>
</View>
<View style={styles.tideModalButtons}>
<TouchableOpacity
style={styles.confirmPulseButton}
onPress={() => {
setShowAddHeirModal(false);
resetAddHeirForm();
}}
>
<Text style={styles.confirmPulseText}>Cancel</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.confirmPulseButton, { backgroundColor: colors.nautical.teal }]}
onPress={handleAddHeir}
>
<Feather name="send" size={16} color="#fff" />
<Text style={[styles.confirmPulseText, { color: '#fff' }]}>Send Invitation</Text>
</TouchableOpacity>
</View>
</View>
</View>
</Modal>
{/* Heir Detail Modal */}
<Modal
visible={showHeirDetailModal}
animationType="fade"
transparent
onRequestClose={() => setShowHeirDetailModal(false)}
>
<View style={styles.spiritOverlay}>
<View style={styles.spiritModal}>
{selectedHeir && (
<>
<View style={styles.heirDetailHeader}>
<View style={styles.heirDetailAvatar}>
<FontAwesome5 name="ship" size={28} color={colors.me.primary} />
</View>
<Text style={styles.heirDetailName}>{selectedHeir.name}</Text>
<View style={[
styles.heirStatusBadge,
{ backgroundColor: `${getHeirStatusBadge(selectedHeir.status).color}15` }
]}>
<Ionicons
name={getHeirStatusBadge(selectedHeir.status).icon as any}
size={12}
color={getHeirStatusBadge(selectedHeir.status).color}
/>
<Text style={[styles.heirStatusText, { color: getHeirStatusBadge(selectedHeir.status).color }]}>
{getHeirStatusBadge(selectedHeir.status).text}
</Text>
</View>
</View>
<View style={styles.heirDetailRows}>
<View style={styles.heirDetailRow}>
<Feather name="mail" size={16} color={colors.me.textSecondary} />
<Text style={styles.heirDetailLabel}>Email</Text>
<Text style={styles.heirDetailValue}>{selectedHeir.email}</Text>
</View>
<View style={styles.heirDetailRow}>
<Feather name="hash" size={16} color={colors.me.textSecondary} />
<Text style={styles.heirDetailLabel}>Order</Text>
<Text style={styles.heirDetailValue}>#{selectedHeir.releaseOrder}</Text>
</View>
<View style={styles.heirDetailRow}>
<Feather name="layers" size={16} color={colors.me.textSecondary} />
<Text style={styles.heirDetailLabel}>Tier</Text>
<Text style={styles.heirDetailValue}>
{selectedHeir.releaseLevel} · {releaseLevelConfig[selectedHeir.releaseLevel].label}
</Text>
</View>
<View style={styles.heirDetailRow}>
<FontAwesome5 name="coins" size={14} color={colors.me.textSecondary} />
<Text style={styles.heirDetailLabel}>Payment</Text>
<Text style={styles.heirDetailValue}>
{selectedHeir.paymentStrategy === 'prepaid' ? 'Prepaid' : 'Pay on Access'}
</Text>
</View>
</View>
<TouchableOpacity
style={styles.closeButton}
onPress={() => setShowHeirDetailModal(false)}
>
<Text style={styles.closeButtonText}>Close</Text>
</TouchableOpacity>
</>
)}
</View>
</View>
</Modal>
{/* Trigger Logic Modal */} {/* Trigger Logic Modal */}
<Modal <Modal
visible={showTriggerModal} visible={showTriggerModal}
@@ -1008,28 +1431,9 @@ const styles = StyleSheet.create({
}, },
scrollContent: { scrollContent: {
paddingHorizontal: spacing.base, paddingHorizontal: spacing.base,
paddingTop: spacing.md,
paddingBottom: 100, paddingBottom: 100,
}, },
// Header
headerRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingTop: spacing.sm,
paddingBottom: spacing.md,
},
headerSpacer: {
width: 40,
},
settingsButton: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: colors.me.cardBackground,
justifyContent: 'center',
alignItems: 'center',
...shadows.soft,
},
// Profile Card // Profile Card
profileCard: { profileCard: {
backgroundColor: colors.me.cardBackground, backgroundColor: colors.me.cardBackground,
@@ -1758,4 +2162,175 @@ const styles = StyleSheet.create({
color: colors.me.primary, color: colors.me.primary,
fontWeight: '600', fontWeight: '600',
}, },
// Fleet Legacy styles
fleetLegalNotice: {
flexDirection: 'row',
backgroundColor: colors.me.cardBackground,
borderRadius: borderRadius.xl,
padding: spacing.base,
marginBottom: spacing.md,
borderLeftWidth: 4,
borderLeftColor: colors.nautical.teal,
...shadows.soft,
},
fleetLegalIcon: {
marginRight: spacing.md,
},
fleetLegalContent: {
flex: 1,
},
fleetLegalTitle: {
fontSize: typography.fontSize.base,
fontWeight: '600',
color: colors.me.text,
marginBottom: spacing.xs,
},
fleetLegalText: {
fontSize: typography.fontSize.sm,
color: colors.me.textSecondary,
lineHeight: typography.fontSize.sm * 1.5,
},
fleetHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
padding: spacing.base,
borderBottomWidth: 1,
borderBottomColor: colors.me.cardBorder,
},
fleetHeaderTitle: {
flex: 1,
fontSize: typography.fontSize.xs,
fontWeight: '700',
color: colors.me.textSecondary,
letterSpacing: typography.letterSpacing.widest,
},
fleetCountBadge: {
backgroundColor: colors.nautical.lightMint,
paddingHorizontal: spacing.sm,
paddingVertical: 4,
borderRadius: borderRadius.full,
},
fleetCountText: {
fontSize: typography.fontSize.xs,
fontWeight: '600',
color: colors.nautical.teal,
},
heirAvatar: {
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: colors.nautical.lightMint,
justifyContent: 'center',
alignItems: 'center',
marginRight: spacing.md,
},
heirNameRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
},
heirStatusBadge: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
paddingHorizontal: spacing.sm,
paddingVertical: 2,
borderRadius: borderRadius.full,
},
heirStatusText: {
fontSize: typography.fontSize.xs,
fontWeight: '600',
},
addTrusteeButton: {
borderRadius: borderRadius.lg,
overflow: 'hidden',
marginBottom: spacing.md,
...shadows.soft,
},
addTrusteeGradient: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: spacing.md,
gap: spacing.sm,
},
addTrusteeText: {
fontSize: typography.fontSize.base,
fontWeight: '700',
color: '#fff',
},
inputLabel: {
fontSize: typography.fontSize.xs,
color: colors.me.textSecondary,
marginBottom: spacing.xs,
marginTop: spacing.md,
fontWeight: '600',
letterSpacing: typography.letterSpacing.wide,
},
heirInput: {
backgroundColor: colors.nautical.paleAqua,
borderRadius: borderRadius.lg,
padding: spacing.base,
fontSize: typography.fontSize.base,
color: colors.me.text,
borderWidth: 1,
borderColor: colors.me.cardBorder,
},
heirDetailHeader: {
alignItems: 'center',
marginBottom: spacing.lg,
paddingBottom: spacing.md,
borderBottomWidth: 1,
borderBottomColor: colors.me.cardBorder,
},
heirDetailAvatar: {
width: 72,
height: 72,
borderRadius: 36,
backgroundColor: colors.nautical.lightMint,
justifyContent: 'center',
alignItems: 'center',
marginBottom: spacing.md,
},
heirDetailName: {
fontSize: typography.fontSize.lg,
fontWeight: '600',
color: colors.me.text,
marginBottom: spacing.sm,
},
heirDetailRows: {
gap: spacing.md,
},
heirDetailRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
},
heirDetailLabel: {
flex: 1,
fontSize: typography.fontSize.base,
color: colors.me.textSecondary,
},
heirDetailValue: {
fontSize: typography.fontSize.base,
color: colors.me.text,
fontWeight: '600',
},
// Heritage Modal
heritageModalContainer: {
flex: 1,
},
heritageCloseButton: {
position: 'absolute',
top: spacing.xl + spacing.lg,
right: spacing.lg,
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: 'rgba(26, 58, 74, 0.65)',
alignItems: 'center',
justifyContent: 'center',
zIndex: 10,
},
}); });

View File

@@ -0,0 +1,290 @@
import React, { useState } from 'react';
import {
View,
Text,
StyleSheet,
TextInput,
TouchableOpacity,
KeyboardAvoidingView,
Platform,
SafeAreaView,
ScrollView,
Alert,
} from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import { Feather, MaterialCommunityIcons } from '@expo/vector-icons';
import { colors, spacing, borderRadius, typography, shadows } from '../theme/colors';
import { useAuth } from '../context/AuthContext';
import { ActivityIndicator } from 'react-native';
export default function RegisterScreen({ navigation }: any) {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const { signUp, isLoading } = useAuth();
const handleRegister = async () => {
setError(null);
if (!name || !email || !password || !confirmPassword) {
setError('Please fill in all fields.');
return;
}
if (password !== confirmPassword) {
setError('Passwords do not match.');
return;
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
setError('Please enter a valid email address.');
return;
}
try {
await signUp({ username: name, email, password });
} catch (err: any) {
setError(err.message || 'Registration failed. Please try again.');
}
};
return (
<View style={styles.container}>
<LinearGradient
colors={[colors.sentinel.backgroundGradientStart, colors.sentinel.backgroundGradientEnd]}
style={styles.gradient}
>
<SafeAreaView style={styles.safeArea}>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.content}
>
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.scrollContent}
>
<TouchableOpacity
style={styles.backButton}
onPress={() => navigation.goBack()}
>
<Feather name="arrow-left" size={24} color={colors.sentinel.text} />
</TouchableOpacity>
<View style={styles.header}>
<View style={styles.logoContainer}>
<MaterialCommunityIcons name="compass-outline" size={48} color={colors.sentinel.primary} />
</View>
<Text style={styles.title}>Join the Fleet</Text>
<Text style={styles.subtitle}>Begin your journey with Sentinel</Text>
{error && (
<View style={styles.errorContainer}>
<MaterialCommunityIcons name="alert-circle-outline" size={20} color={colors.sentinel.statusCritical} />
<Text style={styles.errorText}>{error}</Text>
</View>
)}
</View>
<View style={styles.form}>
<View style={styles.inputContainer}>
<Feather name="user" size={20} color={colors.sentinel.textSecondary} style={styles.inputIcon} />
<TextInput
style={styles.input}
placeholder="Captain's Name"
placeholderTextColor={colors.sentinel.textSecondary}
value={name}
onChangeText={setName}
/>
</View>
<View style={styles.inputContainer}>
<Feather name="mail" size={20} color={colors.sentinel.textSecondary} style={styles.inputIcon} />
<TextInput
style={styles.input}
placeholder="Email"
placeholderTextColor={colors.sentinel.textSecondary}
value={email}
onChangeText={setEmail}
autoCapitalize="none"
keyboardType="email-address"
/>
</View>
<View style={styles.inputContainer}>
<Feather name="lock" size={20} color={colors.sentinel.textSecondary} style={styles.inputIcon} />
<TextInput
style={styles.input}
placeholder="Password"
placeholderTextColor={colors.sentinel.textSecondary}
value={password}
onChangeText={setPassword}
secureTextEntry
/>
</View>
<View style={styles.inputContainer}>
<Feather name="check-circle" size={20} color={colors.sentinel.textSecondary} style={styles.inputIcon} />
<TextInput
style={styles.input}
placeholder="Confirm Password"
placeholderTextColor={colors.sentinel.textSecondary}
value={confirmPassword}
onChangeText={setConfirmPassword}
secureTextEntry
/>
</View>
<TouchableOpacity
style={[styles.registerButton, isLoading && styles.disabledButton]}
activeOpacity={0.8}
onPress={handleRegister}
disabled={isLoading}
>
{isLoading ? (
<ActivityIndicator color={colors.white} />
) : (
<Text style={styles.registerButtonText}>Create Account</Text>
)}
</TouchableOpacity>
<TouchableOpacity
style={styles.loginLink}
onPress={() => navigation.navigate('Login')}
>
<Text style={styles.loginLinkText}>
Already have an account? <Text style={styles.highlight}>Login</Text>
</Text>
</TouchableOpacity>
</View>
</ScrollView>
</KeyboardAvoidingView>
</SafeAreaView>
</LinearGradient>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
gradient: {
flex: 1,
},
safeArea: {
flex: 1,
},
content: {
flex: 1,
},
scrollContent: {
flexGrow: 1,
paddingHorizontal: spacing.lg,
paddingBottom: spacing.xl,
},
backButton: {
marginTop: spacing.md,
marginBottom: spacing.lg,
width: 40,
height: 40,
borderRadius: borderRadius.full,
backgroundColor: 'rgba(255, 255, 255, 0.05)',
alignItems: 'center',
justifyContent: 'center',
},
header: {
alignItems: 'center',
marginBottom: spacing.xl,
},
logoContainer: {
width: 80,
height: 80,
borderRadius: borderRadius.full,
backgroundColor: 'rgba(255, 255, 255, 0.1)',
alignItems: 'center',
justifyContent: 'center',
marginBottom: spacing.lg,
borderWidth: 1,
borderColor: 'rgba(184, 224, 229, 0.2)',
},
title: {
fontSize: typography.fontSize.xxl,
fontWeight: 'bold',
color: colors.sentinel.text,
marginBottom: spacing.xs,
letterSpacing: 0.5,
},
subtitle: {
fontSize: typography.fontSize.base,
color: colors.sentinel.textSecondary,
},
form: {
width: '100%',
},
inputContainer: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(255, 255, 255, 0.05)',
borderRadius: borderRadius.lg,
borderWidth: 1,
borderColor: 'rgba(184, 224, 229, 0.1)',
marginBottom: spacing.base,
height: 56,
paddingHorizontal: spacing.base,
},
inputIcon: {
marginRight: spacing.md,
},
input: {
flex: 1,
color: colors.sentinel.text,
fontSize: typography.fontSize.base,
},
registerButton: {
backgroundColor: colors.nautical.seafoam,
height: 56,
borderRadius: borderRadius.lg,
alignItems: 'center',
justifyContent: 'center',
marginTop: spacing.md,
...shadows.glow,
},
registerButtonText: {
color: colors.white,
fontSize: typography.fontSize.md,
fontWeight: 'bold',
letterSpacing: 0.5,
},
loginLink: {
alignItems: 'center',
marginTop: spacing.xl,
},
loginLinkText: {
color: colors.sentinel.textSecondary,
fontSize: typography.fontSize.base,
},
highlight: {
color: colors.nautical.seafoam,
fontWeight: 'bold',
},
disabledButton: {
opacity: 0.7,
},
errorContainer: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(255, 68, 68, 0.1)',
padding: spacing.sm,
borderRadius: borderRadius.md,
marginTop: spacing.md,
borderWidth: 1,
borderColor: 'rgba(255, 68, 68, 0.2)',
},
errorText: {
color: colors.sentinel.statusCritical,
fontSize: typography.fontSize.sm,
marginLeft: spacing.xs,
fontWeight: '500',
},
});

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { import {
View, View,
Text, Text,
@@ -7,17 +7,101 @@ import {
TouchableOpacity, TouchableOpacity,
SafeAreaView, SafeAreaView,
Animated, Animated,
Modal,
TextInput,
KeyboardAvoidingView,
Platform,
Share,
Alert,
Linking,
} from 'react-native'; } from 'react-native';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import { Ionicons, Feather, MaterialCommunityIcons, FontAwesome5 } from '@expo/vector-icons'; import { Ionicons, Feather, MaterialCommunityIcons, FontAwesome5 } from '@expo/vector-icons';
import { captureRef } from 'react-native-view-shot';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { colors, typography, spacing, borderRadius, shadows } from '../theme/colors'; import { colors, typography, spacing, borderRadius, shadows } from '../theme/colors';
import { SystemStatus, KillSwitchLog } from '../types'; import { SystemStatus, KillSwitchLog } from '../types';
import VaultScreen from './VaultScreen';
import {
SSSShare,
mnemonicToEntropy,
splitSecret,
formatShareCompact,
serializeShare,
verifyShares,
} from '../utils/sss';
// Nautical-themed mnemonic word list (unique words only)
const MNEMONIC_WORDS = [
'anchor', 'harbor', 'compass', 'lighthouse', 'current', 'ocean', 'tide', 'voyage',
'keel', 'stern', 'bow', 'mast', 'sail', 'port', 'starboard', 'reef',
'signal', 'beacon', 'chart', 'helm', 'gale', 'calm', 'cove', 'isle',
'horizon', 'sextant', 'sound', 'drift', 'wake', 'mariner', 'pilot', 'fathom',
'buoy', 'lantern', 'harpoon', 'lagoon', 'bay', 'strait', 'riptide', 'foam',
'coral', 'pearl', 'trident', 'ebb', 'flow', 'vault', 'cipher', 'shroud',
'salt', 'wave', 'grotto', 'storm', 'north', 'south', 'east', 'west',
'ember', 'cabin', 'ledger', 'torch', 'sanctum', 'oath', 'depths', 'captain',
] as const;
// Animation timing constants
const ANIMATION_DURATION = {
pulse: 1200,
glow: 1500,
rotate: 30000,
heartbeatPress: 150,
} as const;
const generateMnemonic = (wordCount = 12) => {
const words: string[] = [];
for (let i = 0; i < wordCount; i += 1) {
const index = Math.floor(Math.random() * MNEMONIC_WORDS.length);
words.push(MNEMONIC_WORDS[index]);
}
return words;
};
/**
* Generate SSS shares from mnemonic words
* Uses Shamir's Secret Sharing (3,2) threshold scheme
*/
const generateSSSShares = (words: string[]): SSSShare[] => {
try {
// Convert mnemonic to entropy (big integer)
const entropy = mnemonicToEntropy(words, MNEMONIC_WORDS);
// Split entropy into 3 shares using SSS
const shares = splitSecret(entropy);
// Verify shares can recover the original (optional, for debugging)
if (__DEV__) {
const isValid = verifyShares(shares, entropy);
if (!isValid) {
console.warn('SSS verification failed!');
} else {
console.log('SSS shares verified successfully');
}
}
return shares;
} catch (error) {
console.error('Failed to generate SSS shares:', error);
// Fallback: return empty shares (should not happen in production)
return [
{ x: 1, y: BigInt(0), label: 'device' },
{ x: 2, y: BigInt(0), label: 'cloud' },
{ x: 3, y: BigInt(0), label: 'heir' },
];
}
};
// Icon names type for type safety
type StatusIconName = 'checkmark-circle' | 'warning' | 'alert-circle';
// Status configuration with nautical theme // Status configuration with nautical theme
const statusConfig: Record<SystemStatus, { const statusConfig: Record<SystemStatus, {
color: string; color: string;
label: string; label: string;
icon: string; icon: StatusIconName;
description: string; description: string;
gradientColors: [string, string]; gradientColors: [string, string];
}> = { }> = {
@@ -76,72 +160,155 @@ export default function SentinelScreen() {
const [pulseAnim] = useState(new Animated.Value(1)); const [pulseAnim] = useState(new Animated.Value(1));
const [glowAnim] = useState(new Animated.Value(0.5)); const [glowAnim] = useState(new Animated.Value(0.5));
const [rotateAnim] = useState(new Animated.Value(0)); const [rotateAnim] = useState(new Animated.Value(0));
const [showVault, setShowVault] = useState(false);
const [showMnemonic, setShowMnemonic] = useState(false);
const [mnemonicWords, setMnemonicWords] = useState<string[]>([]);
const [sssShares, setSssShares] = useState<SSSShare[]>([]);
const [showEmailForm, setShowEmailForm] = useState(false);
const [emailAddress, setEmailAddress] = useState('');
const [isCapturing, setIsCapturing] = useState(false);
const mnemonicRef = useRef<View>(null);
useEffect(() => { useEffect(() => {
// Pulse animation // Pulse animation
Animated.loop( const pulseAnimation = Animated.loop(
Animated.sequence([ Animated.sequence([
Animated.timing(pulseAnim, { Animated.timing(pulseAnim, {
toValue: 1.06, toValue: 1.06,
duration: 1200, duration: ANIMATION_DURATION.pulse,
useNativeDriver: true, useNativeDriver: true,
}), }),
Animated.timing(pulseAnim, { Animated.timing(pulseAnim, {
toValue: 1, toValue: 1,
duration: 1200, duration: ANIMATION_DURATION.pulse,
useNativeDriver: true, useNativeDriver: true,
}), }),
]) ])
).start(); );
pulseAnimation.start();
// Glow animation // Glow animation
Animated.loop( const glowAnimation = Animated.loop(
Animated.sequence([ Animated.sequence([
Animated.timing(glowAnim, { Animated.timing(glowAnim, {
toValue: 1, toValue: 1,
duration: 1500, duration: ANIMATION_DURATION.glow,
useNativeDriver: true, useNativeDriver: true,
}), }),
Animated.timing(glowAnim, { Animated.timing(glowAnim, {
toValue: 0.5, toValue: 0.5,
duration: 1500, duration: ANIMATION_DURATION.glow,
useNativeDriver: true, useNativeDriver: true,
}), }),
]) ])
).start(); );
glowAnimation.start();
// Slow rotate for ship wheel // Slow rotate for ship wheel
Animated.loop( const rotateAnimation = Animated.loop(
Animated.timing(rotateAnim, { Animated.timing(rotateAnim, {
toValue: 1, toValue: 1,
duration: 30000, duration: ANIMATION_DURATION.rotate,
useNativeDriver: true, useNativeDriver: true,
}) })
).start(); );
}, []); rotateAnimation.start();
// Cleanup animations on unmount to prevent memory leaks
return () => {
pulseAnimation.stop();
glowAnimation.stop();
rotateAnimation.stop();
};
}, [pulseAnim, glowAnim, rotateAnim]);
const openVaultWithMnemonic = () => {
const words = generateMnemonic();
const shares = generateSSSShares(words);
setMnemonicWords(words);
setSssShares(shares);
setShowMnemonic(true);
setShowVault(false);
setShowEmailForm(false);
setEmailAddress('');
// Store Share A (device share) locally
if (shares[0]) {
AsyncStorage.setItem('sentinel_share_device', serializeShare(shares[0])).catch(() => {
// Best-effort local store; UI remains available
});
}
};
const handleScreenshot = async () => {
try {
setIsCapturing(true);
const uri = await captureRef(mnemonicRef, {
format: 'png',
quality: 1,
result: 'tmpfile',
});
await Share.share({
url: uri,
message: 'Sentinel key backup',
});
setShowMnemonic(false);
setShowVault(true);
} catch (error) {
Alert.alert('Screenshot failed', 'Please try again or use email backup.');
} finally {
setIsCapturing(false);
}
};
const handleEmailBackup = () => {
setShowEmailForm(true);
};
const handleSendEmail = async () => {
const trimmed = emailAddress.trim();
if (!trimmed || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed)) {
Alert.alert('Invalid email', 'Please enter a valid email address.');
return;
}
const subject = encodeURIComponent('Sentinel Vault Recovery Key');
const body = encodeURIComponent(`Your 12-word mnemonic:\n${mnemonicWords.join(' ')}`);
const mailtoUrl = `mailto:${trimmed}?subject=${subject}&body=${body}`;
try {
await Linking.openURL(mailtoUrl);
setShowMnemonic(false);
setShowEmailForm(false);
setEmailAddress('');
setShowVault(true);
} catch (error) {
Alert.alert('Email failed', 'Unable to open email client.');
}
};
const handleHeartbeat = () => { const handleHeartbeat = () => {
// Animate pulse // Animate pulse
Animated.sequence([ Animated.sequence([
Animated.timing(pulseAnim, { Animated.timing(pulseAnim, {
toValue: 1.15, toValue: 1.15,
duration: 150, duration: ANIMATION_DURATION.heartbeatPress,
useNativeDriver: true, useNativeDriver: true,
}), }),
Animated.timing(pulseAnim, { Animated.timing(pulseAnim, {
toValue: 1, toValue: 1,
duration: 150, duration: ANIMATION_DURATION.heartbeatPress,
useNativeDriver: true, useNativeDriver: true,
}), }),
]).start(); ]).start();
// Add new log // Add new log using functional update to avoid stale closure
const newLog: KillSwitchLog = { const newLog: KillSwitchLog = {
id: Date.now().toString(), id: Date.now().toString(),
action: 'HEARTBEAT_CONFIRMED', action: 'HEARTBEAT_CONFIRMED',
timestamp: new Date(), timestamp: new Date(),
}; };
setLogs([newLog, ...logs]); setLogs((prevLogs) => [newLog, ...prevLogs]);
// Reset status if warning // Reset status if warning
if (status === 'warning') { if (status === 'warning') {
@@ -219,7 +386,7 @@ export default function SentinelScreen() {
colors={currentStatus.gradientColors} colors={currentStatus.gradientColors}
style={styles.statusCircle} style={styles.statusCircle}
> >
<Ionicons name={currentStatus.icon as any} size={56} color="#fff" /> <Ionicons name={currentStatus.icon} size={56} color="#fff" />
</LinearGradient> </LinearGradient>
</Animated.View> </Animated.View>
<Text style={[styles.statusLabel, { color: currentStatus.color }]}> <Text style={[styles.statusLabel, { color: currentStatus.color }]}>
@@ -270,11 +437,35 @@ export default function SentinelScreen() {
</View> </View>
</View> </View>
{/* Shadow Vault Access */}
<View style={styles.vaultAccessCard}>
<View style={styles.vaultAccessIcon}>
<MaterialCommunityIcons name="treasure-chest" size={22} color={colors.nautical.teal} />
</View>
<View style={styles.vaultAccessContent}>
<Text style={styles.vaultAccessTitle}>Shadow Vault</Text>
<Text style={styles.vaultAccessText}>
Access sealed assets from the Lighthouse.
</Text>
</View>
<TouchableOpacity
style={styles.vaultAccessButton}
onPress={openVaultWithMnemonic}
activeOpacity={0.8}
accessibilityLabel="Open Shadow Vault"
accessibilityRole="button"
>
<Text style={styles.vaultAccessButtonText}>Open</Text>
</TouchableOpacity>
</View>
{/* Heartbeat Button */} {/* Heartbeat Button */}
<TouchableOpacity <TouchableOpacity
style={styles.heartbeatButton} style={styles.heartbeatButton}
onPress={handleHeartbeat} onPress={handleHeartbeat}
activeOpacity={0.9} activeOpacity={0.9}
accessibilityLabel="Signal the watch - Confirm your presence"
accessibilityRole="button"
> >
<LinearGradient <LinearGradient
colors={[colors.nautical.teal, colors.nautical.seafoam]} colors={[colors.nautical.teal, colors.nautical.seafoam]}
@@ -313,6 +504,136 @@ export default function SentinelScreen() {
</ScrollView> </ScrollView>
</SafeAreaView> </SafeAreaView>
</LinearGradient> </LinearGradient>
{/* Vault Modal */}
<Modal
visible={showVault}
animationType="slide"
onRequestClose={() => setShowVault(false)}
>
<View style={styles.vaultModalContainer}>
<VaultScreen />
<TouchableOpacity
style={styles.vaultCloseButton}
onPress={() => setShowVault(false)}
activeOpacity={0.85}
accessibilityLabel="Close vault"
accessibilityRole="button"
>
<Ionicons name="close" size={20} color={colors.nautical.cream} />
</TouchableOpacity>
</View>
</Modal>
{/* Mnemonic Modal */}
<Modal
visible={showMnemonic}
animationType="fade"
transparent
onRequestClose={() => setShowMnemonic(false)}
>
<KeyboardAvoidingView
style={styles.mnemonicOverlay}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<View ref={mnemonicRef} collapsable={false}>
<LinearGradient
colors={[colors.sentinel.cardBackground, colors.sentinel.backgroundGradientEnd]}
style={styles.mnemonicCard}
>
<TouchableOpacity
style={styles.mnemonicClose}
onPress={() => setShowMnemonic(false)}
activeOpacity={0.85}
accessibilityLabel="Close mnemonic modal"
accessibilityRole="button"
>
<Ionicons name="close" size={18} color={colors.sentinel.textSecondary} />
</TouchableOpacity>
<View style={styles.mnemonicHeader}>
<MaterialCommunityIcons name="key-variant" size={22} color={colors.sentinel.primary} />
<Text style={styles.mnemonicTitle}>12-Word Mnemonic</Text>
</View>
<Text style={styles.mnemonicSubtitle}>
Your seed is protected by SSS (3,2) threshold encryption. Any 2 shares can restore your vault.
</Text>
<View style={styles.mnemonicBlock}>
<Text style={styles.mnemonicBlockText}>
{mnemonicWords.join(' ')}
</Text>
</View>
<View style={styles.partGrid}>
<View style={[styles.partCard, styles.partCardStored]}>
<Text style={styles.partLabel}>SHARE A DEVICE</Text>
<Text style={styles.partValue}>
{sssShares[0] ? formatShareCompact(sssShares[0]) : '---'}
</Text>
<Text style={styles.partHint}>Stored on this device</Text>
</View>
<View style={styles.partCard}>
<Text style={styles.partLabel}>SHARE B CLOUD</Text>
<Text style={styles.partValue}>
{sssShares[1] ? formatShareCompact(sssShares[1]) : '---'}
</Text>
<Text style={styles.partHint}>To be synced to Sentinel</Text>
</View>
<View style={styles.partCard}>
<Text style={styles.partLabel}>SHARE C HEIR</Text>
<Text style={styles.partValue}>
{sssShares[2] ? formatShareCompact(sssShares[2]) : '---'}
</Text>
<Text style={styles.partHint}>For your heir (2-of-3 required)</Text>
</View>
</View>
<TouchableOpacity
style={[styles.mnemonicPrimaryButton, isCapturing && styles.mnemonicButtonDisabled]}
onPress={handleScreenshot}
activeOpacity={0.85}
disabled={isCapturing}
accessibilityLabel="Take screenshot backup of mnemonic"
accessibilityRole="button"
accessibilityState={{ disabled: isCapturing }}
>
<Text style={styles.mnemonicPrimaryText}>
{isCapturing ? 'CAPTURING...' : 'PHYSICAL BACKUP (SCREENSHOT)'}
</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.mnemonicSecondaryButton}
onPress={handleEmailBackup}
activeOpacity={0.85}
accessibilityLabel="Send mnemonic backup via email"
accessibilityRole="button"
>
<Text style={styles.mnemonicSecondaryText}>EMAIL BACKUP</Text>
</TouchableOpacity>
{showEmailForm ? (
<View style={styles.emailForm}>
<TextInput
style={styles.emailInput}
value={emailAddress}
onChangeText={setEmailAddress}
placeholder="you@email.com"
placeholderTextColor={colors.sentinel.textSecondary}
keyboardType="email-address"
autoCapitalize="none"
autoCorrect={false}
/>
<TouchableOpacity
style={styles.emailSendButton}
onPress={handleSendEmail}
activeOpacity={0.85}
accessibilityLabel="Send backup email"
accessibilityRole="button"
>
<Text style={styles.emailSendText}>SEND EMAIL</Text>
</TouchableOpacity>
</View>
) : null}
</LinearGradient>
</View>
</KeyboardAvoidingView>
</Modal>
</View> </View>
); );
} }
@@ -513,4 +834,208 @@ const styles = StyleSheet.create({
color: colors.sentinel.textSecondary, color: colors.sentinel.textSecondary,
fontFamily: typography.fontFamily.mono, fontFamily: typography.fontFamily.mono,
}, },
// Shadow Vault Access Card
vaultAccessCard: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.sentinel.cardBackground,
borderRadius: borderRadius.xl,
padding: spacing.base,
marginBottom: spacing.lg,
borderWidth: 1,
borderColor: colors.sentinel.cardBorder,
},
vaultAccessIcon: {
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: `${colors.nautical.teal}20`,
alignItems: 'center',
justifyContent: 'center',
marginRight: spacing.md,
},
vaultAccessContent: {
flex: 1,
},
vaultAccessTitle: {
fontSize: typography.fontSize.base,
fontWeight: '600',
color: colors.sentinel.text,
marginBottom: 2,
},
vaultAccessText: {
fontSize: typography.fontSize.sm,
color: colors.sentinel.textSecondary,
},
vaultAccessButton: {
backgroundColor: colors.nautical.teal,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: borderRadius.full,
},
vaultAccessButtonText: {
color: colors.nautical.cream,
fontWeight: '700',
fontSize: typography.fontSize.sm,
},
// Vault Modal
vaultModalContainer: {
flex: 1,
backgroundColor: colors.vault.background,
},
vaultCloseButton: {
position: 'absolute',
top: spacing.xl + spacing.lg,
right: spacing.lg,
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: 'rgba(26, 58, 74, 0.65)',
alignItems: 'center',
justifyContent: 'center',
},
mnemonicOverlay: {
flex: 1,
backgroundColor: 'rgba(11, 20, 24, 0.72)',
justifyContent: 'center',
padding: spacing.lg,
},
mnemonicCard: {
borderRadius: borderRadius.xl,
padding: spacing.lg,
borderWidth: 1,
borderColor: colors.sentinel.cardBorder,
...shadows.glow,
},
mnemonicHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
marginBottom: spacing.sm,
},
mnemonicClose: {
position: 'absolute',
top: spacing.sm,
right: spacing.sm,
width: 32,
height: 32,
borderRadius: 16,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(26, 58, 74, 0.35)',
},
mnemonicTitle: {
fontSize: typography.fontSize.lg,
fontWeight: '700',
color: colors.sentinel.text,
letterSpacing: typography.letterSpacing.wide,
},
mnemonicSubtitle: {
fontSize: typography.fontSize.sm,
color: colors.sentinel.textSecondary,
marginBottom: spacing.md,
},
mnemonicBlock: {
backgroundColor: colors.sentinel.cardBackground,
borderRadius: borderRadius.lg,
paddingVertical: spacing.md,
paddingHorizontal: spacing.md,
borderWidth: 1,
borderColor: colors.sentinel.cardBorder,
marginBottom: spacing.lg,
},
partGrid: {
gap: spacing.sm,
marginBottom: spacing.lg,
},
partCard: {
backgroundColor: colors.sentinel.cardBackground,
borderRadius: borderRadius.lg,
paddingVertical: spacing.sm,
paddingHorizontal: spacing.md,
borderWidth: 1,
borderColor: colors.sentinel.cardBorder,
},
partCardStored: {
borderColor: colors.sentinel.primary,
},
partLabel: {
fontSize: typography.fontSize.xs,
color: colors.sentinel.textSecondary,
letterSpacing: typography.letterSpacing.wide,
marginBottom: 4,
fontWeight: '600',
},
partValue: {
fontSize: typography.fontSize.md,
color: colors.sentinel.text,
fontFamily: typography.fontFamily.mono,
fontWeight: '700',
marginBottom: 2,
},
partHint: {
fontSize: typography.fontSize.xs,
color: colors.sentinel.textSecondary,
},
mnemonicBlockText: {
fontSize: typography.fontSize.sm,
color: colors.sentinel.text,
fontFamily: typography.fontFamily.mono,
fontWeight: '600',
lineHeight: 22,
textAlign: 'center',
},
mnemonicPrimaryButton: {
backgroundColor: colors.sentinel.primary,
paddingVertical: spacing.sm,
borderRadius: borderRadius.full,
alignItems: 'center',
marginBottom: spacing.sm,
},
mnemonicButtonDisabled: {
opacity: 0.6,
},
mnemonicPrimaryText: {
color: colors.nautical.cream,
fontWeight: '700',
letterSpacing: typography.letterSpacing.wide,
},
mnemonicSecondaryButton: {
backgroundColor: 'transparent',
paddingVertical: spacing.sm,
borderRadius: borderRadius.full,
alignItems: 'center',
borderWidth: 1,
borderColor: colors.sentinel.cardBorder,
},
mnemonicSecondaryText: {
color: colors.sentinel.text,
fontWeight: '700',
letterSpacing: typography.letterSpacing.wide,
},
emailForm: {
marginTop: spacing.sm,
},
emailInput: {
height: 44,
borderRadius: borderRadius.full,
borderWidth: 1,
borderColor: colors.sentinel.cardBorder,
paddingHorizontal: spacing.md,
color: colors.sentinel.text,
fontSize: typography.fontSize.sm,
backgroundColor: 'rgba(255, 255, 255, 0.02)',
marginBottom: spacing.sm,
},
emailSendButton: {
backgroundColor: colors.nautical.teal,
paddingVertical: spacing.sm,
borderRadius: borderRadius.full,
alignItems: 'center',
},
emailSendText: {
color: colors.nautical.cream,
fontWeight: '700',
letterSpacing: typography.letterSpacing.wide,
},
}); });

View File

@@ -0,0 +1,79 @@
/**
* Admin Service
*
* Handles admin-only API operations.
*/
import {
NO_BACKEND_MODE,
API_ENDPOINTS,
MOCK_CONFIG,
buildApiUrl,
getApiHeaders,
logApiDebug,
} from '../config';
// =============================================================================
// Type Definitions
// =============================================================================
export interface DeclareGualeRequest {
username: string;
}
export interface DeclareGualeResponse {
message: string;
username: string;
guale: boolean;
}
// =============================================================================
// Admin Service
// =============================================================================
export const adminService = {
/**
* Declare a user as deceased (guale)
* Admin only operation
* @param request - Username to declare as deceased
* @param token - JWT token for authentication (must be admin)
* @returns Success response
*/
async declareGuale(request: DeclareGualeRequest, token: string): Promise<DeclareGualeResponse> {
if (NO_BACKEND_MODE) {
logApiDebug('Declare Guale', 'Using mock mode');
return new Promise((resolve) => {
setTimeout(() => {
resolve({
message: `User ${request.username} has been declared as deceased`,
username: request.username,
guale: true,
});
}, MOCK_CONFIG.RESPONSE_DELAY);
});
}
const url = buildApiUrl(API_ENDPOINTS.ADMIN.DECLARE_GUALE);
logApiDebug('Declare Guale URL', url);
try {
const response = await fetch(url, {
method: 'POST',
headers: getApiHeaders(token),
body: JSON.stringify(request),
});
logApiDebug('Declare Guale Response Status', response.status);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || 'Failed to declare user as deceased');
}
return await response.json();
} catch (error) {
console.error('Declare guale error:', error);
throw error;
}
},
};

243
src/services/ai.service.ts Normal file
View File

@@ -0,0 +1,243 @@
/**
* AI Service
*
* Handles communication with the AI proxy endpoint for chat completions.
*/
import {
NO_BACKEND_MODE,
API_ENDPOINTS,
AI_CONFIG,
buildApiUrl,
getApiHeaders,
logApiDebug,
} from '../config';
// =============================================================================
// Type Definitions
// =============================================================================
export interface AIMessage {
role: 'system' | 'user' | 'assistant';
content: string;
}
export interface AIRequest {
messages: AIMessage[];
model?: string;
}
export interface AIResponse {
id: string;
object: string;
created: number;
model: string;
choices: Array<{
index: number;
message: AIMessage;
finish_reason: string;
}>;
usage: {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
};
}
// =============================================================================
// Mock Response Generator
// =============================================================================
const createMockResponse = (userMessage: string): AIResponse => {
return {
id: `mock-${Date.now()}`,
object: 'chat.completion',
created: Math.floor(Date.now() / 1000),
model: 'mock-model',
choices: [
{
index: 0,
message: {
role: 'assistant',
content: `I received your message: "${userMessage}". This is a mock response since the backend is not connected.`,
},
finish_reason: 'stop',
},
],
usage: {
prompt_tokens: 10,
completion_tokens: 20,
total_tokens: 30,
},
};
};
// =============================================================================
// AI Service
// =============================================================================
export const aiService = {
/**
* Send chat messages to the AI proxy
* @param messages - Array of chat messages
* @param token - JWT token for authentication
* @returns AI response
*/
async chat(messages: AIMessage[], token?: string): Promise<AIResponse> {
if (NO_BACKEND_MODE) {
logApiDebug('AI Chat', 'Using mock mode');
return new Promise((resolve) => {
setTimeout(() => {
const lastUserMessage = messages.filter((m) => m.role === 'user').pop();
resolve(createMockResponse(lastUserMessage?.content || 'Hello'));
}, AI_CONFIG.MOCK_RESPONSE_DELAY);
});
}
const url = buildApiUrl(API_ENDPOINTS.AI.PROXY);
logApiDebug('AI Request', {
url,
hasToken: !!token,
messageCount: messages.length,
});
try {
const response = await fetch(url, {
method: 'POST',
headers: getApiHeaders(token),
body: JSON.stringify({ messages } as AIRequest),
});
logApiDebug('AI Response Status', response.status);
if (!response.ok) {
const errorText = await response.text();
logApiDebug('AI Error Response', errorText);
let errorDetail = 'AI request failed';
try {
const errorData = JSON.parse(errorText);
errorDetail = errorData.detail || errorDetail;
} catch {
errorDetail = errorText || errorDetail;
}
throw new Error(`${response.status}: ${errorDetail}`);
}
const data = await response.json();
logApiDebug('AI Success', {
id: data.id,
model: data.model,
choicesCount: data.choices?.length,
});
return data;
} catch (error) {
console.error('AI proxy error:', error);
throw error;
}
},
/**
* Simple helper for single message chat
* @param content - User message content
* @param token - JWT token for authentication
* @returns AI response text
*/
async sendMessage(content: string, token?: string): Promise<string> {
const messages: AIMessage[] = [
{
role: 'system',
content: AI_CONFIG.DEFAULT_SYSTEM_PROMPT,
},
{
role: 'user',
content,
},
];
const response = await this.chat(messages, token);
return response.choices[0]?.message?.content || 'No response';
},
/**
* Send a message with an image to AI for analysis
* @param content - User message content
* @param imageBase64 - Base64 encoded image data
* @param token - JWT token for authentication
* @returns AI response text
*/
async sendMessageWithImage(content: string, imageBase64: string, token?: string): Promise<string> {
if (NO_BACKEND_MODE) {
logApiDebug('AI Image Analysis', 'Using mock mode');
return new Promise((resolve) => {
setTimeout(() => {
resolve('This is a mock image analysis response. The image appears to show an interesting scene. In production, this would be analyzed by Gemini AI.');
}, AI_CONFIG.MOCK_RESPONSE_DELAY);
});
}
const url = buildApiUrl(API_ENDPOINTS.AI.PROXY);
logApiDebug('AI Image Request', {
url,
hasToken: !!token,
hasImage: !!imageBase64,
});
// Gemini vision format - using multimodal content
const messages = [
{
role: 'user',
content: [
{
type: 'text',
text: content,
},
{
type: 'image_url',
image_url: {
url: `data:image/jpeg;base64,${imageBase64}`,
},
},
],
},
];
try {
const response = await fetch(url, {
method: 'POST',
headers: getApiHeaders(token),
body: JSON.stringify({ messages }),
});
logApiDebug('AI Image Response Status', response.status);
if (!response.ok) {
const errorText = await response.text();
logApiDebug('AI Image Error Response', errorText);
let errorDetail = 'AI image request failed';
try {
const errorData = JSON.parse(errorText);
errorDetail = errorData.detail || errorDetail;
} catch {
errorDetail = errorText || errorDetail;
}
throw new Error(`${response.status}: ${errorDetail}`);
}
const data = await response.json();
logApiDebug('AI Image Success', {
id: data.id,
model: data.model,
});
return data.choices[0]?.message?.content || 'No response';
} catch (error) {
console.error('AI image proxy error:', error);
throw error;
}
},
};

View File

@@ -0,0 +1,243 @@
/**
* Assets Service
*
* Handles all asset-related API operations including CRUD and inheritance.
*/
import {
NO_BACKEND_MODE,
API_ENDPOINTS,
MOCK_CONFIG,
buildApiUrl,
getApiHeaders,
logApiDebug,
} from '../config';
// =============================================================================
// Type Definitions
// =============================================================================
export interface Asset {
id: number;
title: string;
author_id: number;
private_key_shard: string;
content_outer_encrypted: string;
}
export interface AssetCreate {
title: string;
private_key_shard: string;
content_inner_encrypted: string;
}
export interface AssetClaim {
asset_id: number;
private_key_shard: string;
}
export interface AssetClaimResponse {
asset_id: number;
title: string;
decrypted_content: string;
server_shard_key: string;
}
export interface AssetAssign {
asset_id: number;
heir_name: string;
}
// =============================================================================
// Mock Data
// =============================================================================
const MOCK_ASSETS: Asset[] = [
{
id: 1,
title: 'Mock Asset 1',
author_id: MOCK_CONFIG.USER.id,
private_key_shard: 'mock_shard_1',
content_outer_encrypted: 'mock_encrypted_content_1',
},
{
id: 2,
title: 'Mock Asset 2',
author_id: MOCK_CONFIG.USER.id,
private_key_shard: 'mock_shard_2',
content_outer_encrypted: 'mock_encrypted_content_2',
},
];
// =============================================================================
// Assets Service
// =============================================================================
export const assetsService = {
/**
* Get all assets for the current user
* @param token - JWT token for authentication
* @returns Array of user's assets
*/
async getMyAssets(token: string): Promise<Asset[]> {
if (NO_BACKEND_MODE) {
logApiDebug('Get Assets', 'Using mock mode');
return new Promise((resolve) => {
setTimeout(() => resolve(MOCK_ASSETS), MOCK_CONFIG.RESPONSE_DELAY);
});
}
const url = buildApiUrl(API_ENDPOINTS.ASSETS.GET);
logApiDebug('Get Assets URL', url);
try {
const response = await fetch(url, {
method: 'GET',
headers: getApiHeaders(token),
});
logApiDebug('Get Assets Response Status', response.status);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || 'Failed to fetch assets');
}
return await response.json();
} catch (error) {
console.error('Get assets error:', error);
throw error;
}
},
/**
* Create a new asset
* @param asset - Asset creation data
* @param token - JWT token for authentication
* @returns Created asset
*/
async createAsset(asset: AssetCreate, token: string): Promise<Asset> {
if (NO_BACKEND_MODE) {
logApiDebug('Create Asset', 'Using mock mode');
return new Promise((resolve) => {
setTimeout(() => {
resolve({
id: Date.now(),
title: asset.title,
author_id: MOCK_CONFIG.USER.id,
private_key_shard: asset.private_key_shard,
content_outer_encrypted: asset.content_inner_encrypted,
});
}, MOCK_CONFIG.RESPONSE_DELAY);
});
}
const url = buildApiUrl(API_ENDPOINTS.ASSETS.CREATE);
logApiDebug('Create Asset URL', url);
try {
const response = await fetch(url, {
method: 'POST',
headers: getApiHeaders(token),
body: JSON.stringify(asset),
});
logApiDebug('Create Asset Response Status', response.status);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || 'Failed to create asset');
}
return await response.json();
} catch (error) {
console.error('Create asset error:', error);
throw error;
}
},
/**
* Claim an inherited asset
* @param claim - Asset claim data
* @param token - JWT token for authentication
* @returns Claimed asset with decrypted content
*/
async claimAsset(claim: AssetClaim, token: string): Promise<AssetClaimResponse> {
if (NO_BACKEND_MODE) {
logApiDebug('Claim Asset', 'Using mock mode');
return new Promise((resolve) => {
setTimeout(() => {
resolve({
asset_id: claim.asset_id,
title: 'Mock Claimed Asset',
decrypted_content: 'This is the decrypted content of the claimed asset.',
server_shard_key: 'mock_server_shard',
});
}, MOCK_CONFIG.RESPONSE_DELAY);
});
}
const url = buildApiUrl(API_ENDPOINTS.ASSETS.CLAIM);
logApiDebug('Claim Asset URL', url);
try {
const response = await fetch(url, {
method: 'POST',
headers: getApiHeaders(token),
body: JSON.stringify(claim),
});
logApiDebug('Claim Asset Response Status', response.status);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || 'Failed to claim asset');
}
return await response.json();
} catch (error) {
console.error('Claim asset error:', error);
throw error;
}
},
/**
* Assign an asset to an heir
* @param assignment - Asset assignment data
* @param token - JWT token for authentication
* @returns Success message
*/
async assignAsset(assignment: AssetAssign, token: string): Promise<{ message: string }> {
if (NO_BACKEND_MODE) {
logApiDebug('Assign Asset', 'Using mock mode');
return new Promise((resolve) => {
setTimeout(() => {
resolve({ message: `Asset assigned to ${assignment.heir_name}` });
}, MOCK_CONFIG.RESPONSE_DELAY);
});
}
const url = buildApiUrl(API_ENDPOINTS.ASSETS.ASSIGN);
logApiDebug('Assign Asset URL', url);
try {
const response = await fetch(url, {
method: 'POST',
headers: getApiHeaders(token),
body: JSON.stringify(assignment),
});
logApiDebug('Assign Asset Response Status', response.status);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || 'Failed to assign asset');
}
return await response.json();
} catch (error) {
console.error('Assign asset error:', error);
throw error;
}
},
};

View File

@@ -0,0 +1,87 @@
import { LoginRequest, LoginResponse, RegisterRequest, User } from '../types';
import {
NO_BACKEND_MODE,
API_ENDPOINTS,
MOCK_CONFIG,
buildApiUrl,
getApiHeaders,
logApiDebug,
} from '../config';
export const authService = {
async login(credentials: LoginRequest): Promise<LoginResponse> {
if (NO_BACKEND_MODE) {
logApiDebug('Login', 'Using mock mode');
return new Promise((resolve) => {
setTimeout(() => {
resolve({
access_token: MOCK_CONFIG.ACCESS_TOKEN,
token_type: 'bearer',
user: { ...MOCK_CONFIG.USER, username: credentials.username } as User,
});
}, MOCK_CONFIG.RESPONSE_DELAY);
});
}
const url = buildApiUrl(API_ENDPOINTS.AUTH.LOGIN);
logApiDebug('Login URL', url);
try {
const response = await fetch(url, {
method: 'POST',
headers: getApiHeaders(),
body: JSON.stringify(credentials),
});
logApiDebug('Login Response Status', response.status);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || 'Login failed');
}
const data = await response.json();
logApiDebug('Login Success', { username: data.user?.username });
return data;
} catch (error) {
console.error('Login error:', error);
throw error;
}
},
async register(data: RegisterRequest): Promise<User> {
if (NO_BACKEND_MODE) {
logApiDebug('Register', 'Using mock mode');
return new Promise((resolve) => {
setTimeout(() => {
resolve({ ...MOCK_CONFIG.USER, username: data.username } as User);
}, MOCK_CONFIG.RESPONSE_DELAY);
});
}
const url = buildApiUrl(API_ENDPOINTS.AUTH.REGISTER);
logApiDebug('Register URL', url);
try {
const response = await fetch(url, {
method: 'POST',
headers: getApiHeaders(),
body: JSON.stringify(data),
});
logApiDebug('Register Response Status', response.status);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail?.[0]?.msg || errorData.detail || 'Registration failed');
}
const result = await response.json();
logApiDebug('Register Success', { username: result.username });
return result;
} catch (error) {
console.error('Registration error:', error);
throw error;
}
},
};

25
src/services/index.ts Normal file
View File

@@ -0,0 +1,25 @@
/**
* Services Index
*
* Central export for all API services.
* Import services from here for cleaner imports.
*
* Usage:
* import { authService, aiService, assetsService, adminService } from '../services';
*/
export { authService } from './auth.service';
export { aiService, type AIMessage, type AIRequest, type AIResponse } from './ai.service';
export {
assetsService,
type Asset,
type AssetCreate,
type AssetClaim,
type AssetClaimResponse,
type AssetAssign
} from './assets.service';
export {
adminService,
type DeclareGualeRequest,
type DeclareGualeResponse
} from './admin.service';

View File

@@ -72,3 +72,32 @@ export interface ProtocolInfo {
version: string; version: string;
lastUpdated: Date; lastUpdated: Date;
} }
// Auth Types
export interface User {
id: number;
username: string;
public_key: string;
is_admin: boolean;
guale: boolean;
tier: string;
tier_expires_at: string;
last_active_at: string;
}
export interface LoginRequest {
username: string;
password: string;
}
export interface RegisterRequest {
username: string;
password: string;
email: string;
}
export interface LoginResponse {
access_token: string;
token_type: string;
user: User;
}

5
src/utils/index.ts Normal file
View File

@@ -0,0 +1,5 @@
/**
* Utility functions for Sentinel
*/
export * from './sss';

263
src/utils/sss.ts Normal file
View File

@@ -0,0 +1,263 @@
/**
* Shamir's Secret Sharing (SSS) Implementation
*
* This implements a (3,2) threshold scheme where:
* - Secret is split into 3 shares
* - Any 2 shares can recover the original secret
*
* Based on the Sentinel crypto_core_demo Python implementation.
*/
// Use a large prime for the finite field
// We use 2^127 - 1 (a Mersenne prime) which fits well in BigInt
// This is smaller than the Python version's 2^521 - 1 but sufficient for our 128-bit entropy
const PRIME = BigInt('170141183460469231731687303715884105727'); // 2^127 - 1
/**
* Represents an SSS share as a coordinate point (x, y)
*/
export interface SSSShare {
x: number;
y: bigint;
label: string; // 'device' | 'cloud' | 'heir'
}
/**
* Generate a cryptographically secure random BigInt in range [0, max)
*/
function secureRandomBigInt(max: bigint): bigint {
// Get the number of bytes needed
const byteLength = Math.ceil(max.toString(2).length / 8);
const randomBytes = new Uint8Array(byteLength);
// Use crypto.getRandomValues for secure randomness
if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
crypto.getRandomValues(randomBytes);
} else {
// Fallback for environments without crypto
for (let i = 0; i < byteLength; i++) {
randomBytes[i] = Math.floor(Math.random() * 256);
}
}
// Convert to BigInt
let result = BigInt(0);
for (let i = 0; i < randomBytes.length; i++) {
result = (result << BigInt(8)) + BigInt(randomBytes[i]);
}
// Ensure result is within range
return result % max;
}
/**
* Convert mnemonic words to entropy (as BigInt)
* Each word is mapped to its index, then combined into a single large number
*/
export function mnemonicToEntropy(words: string[], wordList: readonly string[]): bigint {
let entropy = BigInt(0);
const wordListLength = BigInt(wordList.length);
for (const word of words) {
const index = wordList.indexOf(word);
if (index === -1) {
throw new Error(`Word "${word}" not found in word list`);
}
entropy = entropy * wordListLength + BigInt(index);
}
return entropy;
}
/**
* Convert entropy back to mnemonic words
*/
export function entropyToMnemonic(entropy: bigint, wordCount: number, wordList: readonly string[]): string[] {
const words: string[] = [];
const wordListLength = BigInt(wordList.length);
let remaining = entropy;
for (let i = 0; i < wordCount; i++) {
const index = Number(remaining % wordListLength);
words.unshift(wordList[index]);
remaining = remaining / wordListLength;
}
return words;
}
/**
* Modular inverse using extended Euclidean algorithm
* Returns x such that (a * x) % p === 1
*/
function modInverse(a: bigint, p: bigint): bigint {
let [oldR, r] = [a % p, p];
let [oldS, s] = [BigInt(1), BigInt(0)];
while (r !== BigInt(0)) {
const quotient = oldR / r;
[oldR, r] = [r, oldR - quotient * r];
[oldS, s] = [s, oldS - quotient * s];
}
// Ensure positive result
return ((oldS % p) + p) % p;
}
/**
* Modular arithmetic helper to ensure positive results
*/
function mod(n: bigint, p: bigint): bigint {
return ((n % p) + p) % p;
}
/**
* Split a secret into 3 shares using SSS (3,2) threshold scheme
*
* Uses linear polynomial: f(x) = secret + a*x (mod p)
* where 'a' is a random coefficient
*
* Any 2 points on this line can recover the y-intercept (secret)
*/
export function splitSecret(secret: bigint): SSSShare[] {
// Generate random coefficient for the polynomial
const a = secureRandomBigInt(PRIME);
// Polynomial: f(x) = secret + a*x (mod PRIME)
const f = (x: number): bigint => {
return mod(secret + a * BigInt(x), PRIME);
};
// Generate 3 shares at x = 1, 2, 3
return [
{ x: 1, y: f(1), label: 'device' },
{ x: 2, y: f(2), label: 'cloud' },
{ x: 3, y: f(3), label: 'heir' },
];
}
/**
* Recover the secret from any 2 shares using Lagrange interpolation
*
* For 2 points (x1, y1) and (x2, y2), the secret (y-intercept) is:
* S = (x2*y1 - x1*y2) / (x2 - x1) (mod p)
*/
export function recoverSecret(shareA: SSSShare, shareB: SSSShare): bigint {
const { x: x1, y: y1 } = shareA;
const { x: x2, y: y2 } = shareB;
// Numerator: x2*y1 - x1*y2
const numerator = mod(
BigInt(x2) * y1 - BigInt(x1) * y2,
PRIME
);
// Denominator: x2 - x1
const denominator = mod(BigInt(x2 - x1), PRIME);
// Division in modular arithmetic = multiply by modular inverse
const invDenominator = modInverse(denominator, PRIME);
return mod(numerator * invDenominator, PRIME);
}
/**
* Format a share for display (truncated for readability)
* Shows first 8 and last 4 characters of the y-value
*/
export function formatShareForDisplay(share: SSSShare): string {
const yStr = share.y.toString();
if (yStr.length <= 16) {
return `(${share.x}, ${yStr})`;
}
return `(${share.x}, ${yStr.slice(0, 8)}...${yStr.slice(-4)})`;
}
/**
* Format a share as a compact display string (for UI cards)
* Returns a shorter format showing the share index and a hash-like preview
*/
export function formatShareCompact(share: SSSShare): string {
const yStr = share.y.toString();
// Create a "fingerprint" from the y value
const fingerprint = yStr.slice(0, 4) + '-' + yStr.slice(4, 8) + '-' + yStr.slice(-4);
return fingerprint;
}
/**
* Serialize a share to a string for storage/transmission
*/
export function serializeShare(share: SSSShare): string {
return JSON.stringify({
x: share.x,
y: share.y.toString(),
label: share.label,
});
}
/**
* Deserialize a share from a string
*/
export function deserializeShare(str: string): SSSShare {
const parsed = JSON.parse(str);
return {
x: parsed.x,
y: BigInt(parsed.y),
label: parsed.label,
};
}
/**
* Main function to generate mnemonic and SSS shares
* This is the entry point for the vault initialization flow
*/
export interface VaultKeyData {
mnemonic: string[];
shares: SSSShare[];
entropy: bigint;
}
export function generateVaultKeys(
wordList: readonly string[],
wordCount: number = 12
): VaultKeyData {
// Generate random mnemonic
const mnemonic: string[] = [];
for (let i = 0; i < wordCount; i++) {
const index = Math.floor(Math.random() * wordList.length);
mnemonic.push(wordList[index]);
}
// Convert to entropy
const entropy = mnemonicToEntropy(mnemonic, wordList);
// Split into shares
const shares = splitSecret(entropy);
return { mnemonic, shares, entropy };
}
/**
* Verify that shares can recover the original entropy
* Useful for testing and validation
*/
export function verifyShares(
shares: SSSShare[],
originalEntropy: bigint
): boolean {
// Test all 3 combinations of 2 shares
const combinations = [
[shares[0], shares[1]], // Device + Cloud
[shares[1], shares[2]], // Cloud + Heir
[shares[0], shares[2]], // Device + Heir
];
for (const [a, b] of combinations) {
const recovered = recoverSecret(a, b);
if (recovered !== originalEntropy) {
return false;
}
}
return true;
}