Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e2b8f2c412 | |||
| 4aed2be397 |
118
.gitignore
vendored
118
.gitignore
vendored
@@ -1,37 +1,81 @@
|
||||
# -----------------------------------------------------------
|
||||
# Mono-repo ignore list focused on trimming heavy directories
|
||||
# -----------------------------------------------------------
|
||||
|
||||
# macOS / OS metadata
|
||||
.DS_Store
|
||||
*.DS_Store
|
||||
|
||||
# Logs
|
||||
# Logs & diagnostics
|
||||
logs/
|
||||
*.log
|
||||
*.pid
|
||||
*.trace
|
||||
*.stackdump
|
||||
*.seed
|
||||
*.lcov
|
||||
.docgen
|
||||
.vitest
|
||||
|
||||
# Cache
|
||||
# IDE & tooling state
|
||||
.axe/
|
||||
.bundle/
|
||||
.happo/
|
||||
.idea/
|
||||
.nhost/
|
||||
.npm/
|
||||
.vagrant/
|
||||
.eslintcache
|
||||
.yarnclean
|
||||
.pnpm-store/
|
||||
.turbo/
|
||||
.vercel/
|
||||
.netlify/
|
||||
.claude/
|
||||
.monorepo-example/
|
||||
/.vscode/
|
||||
.eslintcache
|
||||
.tsbuildinfo
|
||||
|
||||
# Directories
|
||||
# Cache / dependency folders
|
||||
node_modules/
|
||||
**/node_modules/
|
||||
tmp/
|
||||
out/
|
||||
coverage/
|
||||
dist/
|
||||
umd/
|
||||
node_modules
|
||||
tmp/
|
||||
.pnpm-store
|
||||
.turbo
|
||||
build/
|
||||
docs/build/
|
||||
dashboard/build/
|
||||
|
||||
# Framework build outputs
|
||||
.next/
|
||||
dashboard/.next/
|
||||
dashboard/.turbo/
|
||||
dashboard/out/
|
||||
docs/.next/
|
||||
docs/.docusaurus/
|
||||
packages/**/.turbo/
|
||||
packages/**/dist/
|
||||
packages/**/build/
|
||||
services/**/dist/
|
||||
services/**/build/
|
||||
cli/dist/
|
||||
cli/build/
|
||||
observability/**/dist/
|
||||
observability/**/build/
|
||||
functions/**/dist/
|
||||
tools/**/dist/
|
||||
*.tsbuildinfo
|
||||
|
||||
# Env / secrets
|
||||
.env*
|
||||
.secrets
|
||||
out/
|
||||
.secrets/
|
||||
letsencrypt/*
|
||||
.envrc
|
||||
.direnv/
|
||||
|
||||
# Custom
|
||||
*.min.js
|
||||
*.map
|
||||
|
||||
# Config files that are not part of the repository root anymore. Should be removed in the future.
|
||||
# Config files no longer tracked at repo root
|
||||
/.eslintignore
|
||||
/.eslintrc*
|
||||
/vite.*.js
|
||||
@@ -39,37 +83,17 @@ out/
|
||||
/*tsconfig*.json
|
||||
/esbuild.*.js
|
||||
|
||||
# Keep config package under version control
|
||||
!config/**
|
||||
|
||||
*.tsbuildinfo
|
||||
|
||||
# Local Netlify folder
|
||||
.netlify
|
||||
.monorepo-example
|
||||
|
||||
# Local Vercel folder
|
||||
.vercel
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
|
||||
# TypeDoc output
|
||||
|
||||
.docgen
|
||||
|
||||
# Nhost CLI data
|
||||
.nhost
|
||||
|
||||
# Nix
|
||||
.envrc
|
||||
.direnv/
|
||||
|
||||
/.vscode/
|
||||
|
||||
result
|
||||
|
||||
.vitest
|
||||
|
||||
.claude
|
||||
|
||||
letsencrypt/*
|
||||
# Optional heavy workspaces (remove when you need them tracked)
|
||||
assets/
|
||||
cli/
|
||||
dashboard/
|
||||
deploy/
|
||||
deprecated/
|
||||
docs/
|
||||
nixops/
|
||||
observability/
|
||||
services/
|
||||
vendor/
|
||||
|
||||
5
.zed/settings.json
Normal file
5
.zed/settings.json
Normal file
@@ -0,0 +1,5 @@
|
||||
// Folder-specific settings
|
||||
//
|
||||
// For a full list of overridable settings, and general information on folder-specific settings,
|
||||
// see the documentation: https://zed.dev/docs/configuring-zed#settings-files
|
||||
{}
|
||||
2
dashboard/pnpm-lock.yaml
generated
2
dashboard/pnpm-lock.yaml
generated
@@ -9,7 +9,7 @@ overrides:
|
||||
js-yaml@<=4.1.0: '>=4.1.1'
|
||||
glob@>=10.3.7 <=11.0.3: '>=11.1.0'
|
||||
|
||||
packageExtensionsChecksum: sha256-gRFeykwiwMfEE6etcYx6N48XwVeKzxbqNveL7KTQgSQ=
|
||||
packageExtensionsChecksum: 60d08afe00baf23dd9656c15a43f9c54
|
||||
|
||||
importers:
|
||||
|
||||
|
||||
2
docs/pnpm-lock.yaml
generated
2
docs/pnpm-lock.yaml
generated
@@ -8,7 +8,7 @@ overrides:
|
||||
js-yaml@<=4.1.0: '>=4.1.1'
|
||||
glob@>=10.3.7 <=11.0.3: '>=11.1.0'
|
||||
|
||||
packageExtensionsChecksum: sha256-4+NJJHoeDEOtWI2UxgTNLimXyrOojBs00S85/9Babm0=
|
||||
packageExtensionsChecksum: ff1808c55b54aac157f52213f5265af9
|
||||
|
||||
importers:
|
||||
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
ROOT_DIR?=$(abspath ../..)
|
||||
include $(ROOT_DIR)/build/makefiles/general.makefile
|
||||
|
||||
|
||||
.PHONY: _dev-env-up
|
||||
_dev-env-up:
|
||||
@echo "Nothing to do"
|
||||
|
||||
|
||||
.PHONY: _dev-env-down
|
||||
_dev-env-down:
|
||||
@echo "Nothing to do"
|
||||
|
||||
|
||||
.PHONY: _dev-env-build
|
||||
_dev-env-build:
|
||||
@echo "Nothing to do"
|
||||
39
examples/demos/ReactNativeDemo/.gitignore
vendored
39
examples/demos/ReactNativeDemo/.gitignore
vendored
@@ -1,39 +0,0 @@
|
||||
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
# Expo
|
||||
.expo/
|
||||
dist/
|
||||
web-build/
|
||||
expo-env.d.ts
|
||||
|
||||
# Native
|
||||
.kotlin/
|
||||
*.orig.*
|
||||
*.jks
|
||||
*.p8
|
||||
*.p12
|
||||
*.key
|
||||
*.mobileprovision
|
||||
|
||||
# Metro
|
||||
.metro-health-check*
|
||||
|
||||
# debug
|
||||
npm-debug.*
|
||||
yarn-debug.*
|
||||
yarn-error.*
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
|
||||
app-example
|
||||
@@ -1,95 +0,0 @@
|
||||
# Setting Up Apple Sign In for Nhost Authentication
|
||||
|
||||
This guide will walk you through the steps to configure Apple Sign In for your React Native application with Nhost authentication.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- An Apple Developer account
|
||||
- Xcode 11 or later
|
||||
- Access to the Apple Developer portal
|
||||
|
||||
## 1. Configure Your App in the Apple Developer Portal
|
||||
|
||||
1. Log in to the [Apple Developer portal](https://developer.apple.com/)
|
||||
2. Go to "Certificates, Identifiers & Profiles"
|
||||
3. Select "Identifiers" and create a new App ID if you haven't already
|
||||
4. Enable "Sign In with Apple" capability for your App ID
|
||||
5. Save your changes
|
||||
|
||||
## 2. Create a Service ID for Sign In with Apple
|
||||
|
||||
1. In the Apple Developer portal, go to "Certificates, Identifiers & Profiles"
|
||||
2. Select "Identifiers" and click the "+" button to add a new identifier
|
||||
3. Choose "Services IDs" and click "Continue"
|
||||
4. Enter a description and identifier (e.g., "com.nhost.reactnativewebdemo.service")
|
||||
5. Check "Sign In with Apple" and click "Configure"
|
||||
6. Add your domain to the "Domains and Subdomains" field
|
||||
7. Add your return URL in the "Return URLs" field. This should match your Nhost redirect URL
|
||||
8. Save and register the service ID
|
||||
|
||||
## 3. Configure Nhost for Apple Sign In
|
||||
|
||||
1. In your Nhost dashboard, go to Authentication > Providers > Apple
|
||||
2. Enable the provider
|
||||
3. Enter the following details:
|
||||
- Team ID: Found in your Apple Developer account
|
||||
- Service ID: The identifier you created in step 2
|
||||
- Key ID: Create a new key with "Sign In with Apple" enabled in the Apple Developer portal
|
||||
- Private Key: The downloaded key file content
|
||||
|
||||
## 4. Configure Your Expo/React Native App
|
||||
|
||||
1. Make sure the `expo-apple-authentication` package is installed
|
||||
|
||||
```
|
||||
npx expo install expo-apple-authentication
|
||||
```
|
||||
|
||||
2. Ensure your app.json has the proper configuration:
|
||||
|
||||
```json
|
||||
{
|
||||
"expo": {
|
||||
"ios": {
|
||||
"bundleIdentifier": "com.nhost.reactnativewebdemo",
|
||||
"infoPlist": {
|
||||
"NSFaceIDUsageDescription": "This app uses Face ID for signing in"
|
||||
}
|
||||
},
|
||||
"plugins": ["expo-apple-authentication"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. If you're using EAS Build, make sure you've configured your Apple Developer Team ID:
|
||||
```
|
||||
eas credentials
|
||||
```
|
||||
|
||||
## 5. Testing Apple Sign In
|
||||
|
||||
When you build your app for iOS:
|
||||
|
||||
1. Use a real device or simulator running iOS 13 or later
|
||||
2. Make sure you're signed into an Apple ID on the device
|
||||
3. Use the Apple Sign In button and authenticate
|
||||
4. The app will receive an ID token that is sent to Nhost
|
||||
5. Nhost will verify the token and create or authenticate the user
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Invalid Client ID**: Ensure your Service ID is properly configured in the Apple Developer portal
|
||||
- **Authentication Failed**: Check that your Nhost Apple provider configuration is correct
|
||||
- **App Build Issues**: Ensure the `expo-apple-authentication` package is properly installed and your app.json is configured correctly
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- Never store Apple private keys in your front-end code
|
||||
- The authentication process should always validate tokens on the server side (which Nhost handles)
|
||||
- Keep your Apple Developer account secure
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- [Apple Sign In Documentation](https://developer.apple.com/sign-in-with-apple/)
|
||||
- [Nhost Authentication Documentation](https://docs.nhost.io/authentication)
|
||||
- [Expo Apple Authentication Documentation](https://docs.expo.dev/versions/latest/sdk/apple-authentication/)
|
||||
@@ -1,169 +0,0 @@
|
||||
# Nhost SDK Demo - React Native
|
||||
|
||||
This is a comprehensive React Native demo showcasing the Nhost SDK integration with Expo. The application demonstrates various authentication methods, user management, file operations, and GraphQL interactions in a modern React Native environment.
|
||||
|
||||
## Features
|
||||
|
||||
- **Email/Password Authentication** - Traditional sign-up and sign-in with email
|
||||
- **Multi-Factor Authentication (MFA)** - TOTP-based 2FA security
|
||||
- **Magic Link Authentication** - Passwordless authentication via email
|
||||
- **Social Authentication** - GitHub OAuth integration
|
||||
- **Native Authentication** - Apple Sign-In for iOS devices
|
||||
- **User Profile Management** - Display and manage user information
|
||||
- **Protected Routes** - Route-based authentication guards
|
||||
- **Session Persistence** - Reliable session storage with AsyncStorage
|
||||
- **File Operations** - Upload and download functionality
|
||||
- **GraphQL Operations** - Database queries and mutations
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. **Install dependencies**
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
2. **Configure Nhost**
|
||||
|
||||
Update `app.json` with your Nhost configuration:
|
||||
|
||||
```json
|
||||
"extra": {
|
||||
"NHOST_SUBDOMAIN": "your-subdomain",
|
||||
"NHOST_REGION": "your-region"
|
||||
}
|
||||
```
|
||||
|
||||
For local development with Nhost CLI:
|
||||
|
||||
```json
|
||||
"extra": {
|
||||
"NHOST_SUBDOMAIN": "192-168-1-103",
|
||||
"NHOST_REGION": "local"
|
||||
}
|
||||
```
|
||||
|
||||
_(Replace with your actual local IP address using hyphens instead of dots)_
|
||||
|
||||
3. **Start the development server**
|
||||
|
||||
```bash
|
||||
pnpm start
|
||||
```
|
||||
|
||||
4. **Open the app**
|
||||
- Scan QR code with Expo Go
|
||||
- Press `i` for iOS Simulator
|
||||
- Press `a` for Android Emulator
|
||||
|
||||
## Project Structure
|
||||
|
||||
The project uses [Expo Router](https://docs.expo.dev/router/introduction/) for file-based navigation:
|
||||
|
||||
```
|
||||
ReactNativeWebDemo/
|
||||
├── app/
|
||||
│ ├── _layout.tsx # Root layout with AuthProvider
|
||||
│ ├── index.tsx # Home/landing screen
|
||||
│ ├── signin.tsx # Authentication hub with tabs
|
||||
│ ├── signup.tsx # User registration
|
||||
│ ├── profile.tsx # Protected user profile
|
||||
│ ├── upload.tsx # File upload demo
|
||||
│ ├── verify.tsx # Magic link/social auth verification
|
||||
│ │
|
||||
│ ├── components/
|
||||
│ │ ├── ProtectedScreen.tsx # Route protection wrapper
|
||||
│ │ ├── MagicLinkForm.tsx # Magic link authentication
|
||||
│ │ ├── SocialLoginForm.tsx # GitHub OAuth
|
||||
│ │ ├── NativeLoginForm.tsx # Native auth container
|
||||
│ │ ├── AppleSignIn.tsx # Apple Sign-In (iOS)
|
||||
│ │ └── MFASettings.tsx # Multi-factor authentication
|
||||
│ │
|
||||
│ └── lib/
|
||||
│ ├── nhost/
|
||||
│ │ ├── AuthProvider.tsx # Authentication context
|
||||
│ │ └── AsyncStorage.tsx # Session persistence adapter
|
||||
│ └── utils.ts # Utility functions
|
||||
│
|
||||
├── assets/ # App icons and images
|
||||
├── app.json # Expo configuration
|
||||
└── README files # Documentation (this file and others)
|
||||
```
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
### Authentication Flow
|
||||
|
||||
1. **AuthProvider** wraps the entire app providing global auth state
|
||||
2. **ProtectedScreen** component guards routes requiring authentication
|
||||
3. **Session persistence** maintains login state across app restarts
|
||||
4. **Deep linking** handles magic links and OAuth redirects
|
||||
|
||||
### Key Components
|
||||
|
||||
- **AuthProvider**: Central authentication state management
|
||||
- **ProtectedScreen**: Higher-order component for route protection
|
||||
- **Verification flows**: Unified handling for magic links and OAuth callbacks
|
||||
- **Storage adapter**: Custom AsyncStorage implementation for session persistence
|
||||
|
||||
### Supported Authentication Methods
|
||||
|
||||
1. **Email/Password**: Traditional username/password with MFA support
|
||||
2. **Magic Links**: Passwordless authentication via email verification
|
||||
3. **Social OAuth**: GitHub integration with redirect handling
|
||||
4. **Native Authentication**: Apple Sign-In using secure enclave
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Set these values in `app.json` under the `extra` section:
|
||||
|
||||
| Variable | Description | Example |
|
||||
| ----------------- | ---------------------------- | ------------- |
|
||||
| `NHOST_SUBDOMAIN` | Your Nhost project subdomain | `"myproject"` |
|
||||
| `NHOST_REGION` | Nhost region | `"us-east-1"` |
|
||||
|
||||
### Deep Linking Setup
|
||||
|
||||
The app is configured with the scheme `reactnativewebdemo://` for standalone builds and uses Expo's linking system for development.
|
||||
|
||||
## Development
|
||||
|
||||
### Local Nhost Backend
|
||||
|
||||
To run against a local Nhost backend:
|
||||
|
||||
1. Start Nhost CLI:
|
||||
|
||||
```bash
|
||||
nhost dev
|
||||
```
|
||||
|
||||
2. Update `app.json`:
|
||||
```json
|
||||
"extra": {
|
||||
"NHOST_REGION": "local",
|
||||
"NHOST_SUBDOMAIN": "local"
|
||||
}
|
||||
```
|
||||
|
||||
### Testing Authentication
|
||||
|
||||
- Use the sign-in screen's tabbed interface to test different auth methods
|
||||
- Magic links work in development through proper deep link configuration
|
||||
- Social authentication requires OAuth app setup in your Nhost dashboard
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Protected Routes & Email Auth](./README_PROTECTED_ROUTES.md)
|
||||
- [Native Authentication](./README_NATIVE_AUTHENTICATION.md)
|
||||
- [Magic Links](./README_MAGIC_LINKS.md)
|
||||
- [Social Sign-In](./README_SOCIAL_SIGNIN.md)
|
||||
|
||||
## Learn More
|
||||
|
||||
- [Nhost Documentation](https://docs.nhost.io/)
|
||||
- [Expo Router Documentation](https://docs.expo.dev/router/)
|
||||
- [React Native Documentation](https://reactnative.dev/)
|
||||
- [Expo Documentation](https://docs.expo.dev/)
|
||||
@@ -1,410 +0,0 @@
|
||||
# Magic Links Authentication
|
||||
|
||||
This document explains how magic links (passwordless authentication) are implemented in the Nhost React Native demo, including deep linking configuration, verification endpoints, and testing strategies.
|
||||
|
||||
## Overview
|
||||
|
||||
Magic links provide a passwordless authentication method where users receive an email containing a link that automatically authenticates them when clicked. This implementation handles both Expo Go development and standalone app scenarios.
|
||||
|
||||
## How Magic Links Work
|
||||
|
||||
### Authentication Flow
|
||||
|
||||
1. **Email Collection**: User enters their email address
|
||||
2. **Link Generation**: App requests magic link from Nhost with appropriate redirect URL
|
||||
3. **Email Delivery**: Nhost sends email with authentication link
|
||||
4. **Link Click**: User clicks link, which opens the app via deep linking
|
||||
5. **Token Extraction**: App extracts refresh token from the URL parameters
|
||||
6. **Authentication**: App uses refresh token to authenticate with Nhost
|
||||
7. **Redirect**: User is redirected to their profile upon successful authentication
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### MagicLinkForm Component
|
||||
|
||||
```typescript
|
||||
// app/components/MagicLinkForm.tsx
|
||||
export default function MagicLinkForm() {
|
||||
const [email, setEmail] = useState<string>("");
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [success, setSuccess] = useState<boolean>(false);
|
||||
const { nhost } = useAuth();
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Create the correct redirect URL for current environment
|
||||
const redirectUrl = Linking.createURL("verify");
|
||||
|
||||
await nhost.auth.signInPasswordlessEmail({
|
||||
email,
|
||||
options: {
|
||||
redirectTo: redirectUrl,
|
||||
},
|
||||
});
|
||||
|
||||
setSuccess(true);
|
||||
} catch (err) {
|
||||
const error = err as FetchError<ErrorResponse>;
|
||||
setError(
|
||||
`An error occurred while sending the magic link: ${error.message}`,
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ... UI implementation
|
||||
}
|
||||
```
|
||||
|
||||
### Key Features
|
||||
|
||||
1. **Environment Detection**: Automatically generates correct redirect URLs for Expo Go vs standalone
|
||||
2. **Error Handling**: Comprehensive error handling with user-friendly messages
|
||||
3. **Loading States**: Visual feedback during magic link generation
|
||||
4. **Success Feedback**: Confirmation when magic link is sent
|
||||
|
||||
## Deep Linking Configuration
|
||||
|
||||
### App Configuration
|
||||
|
||||
The app supports deep linking through custom URL schemes:
|
||||
|
||||
```json
|
||||
// app.json
|
||||
{
|
||||
"expo": {
|
||||
"scheme": "reactnativewebdemo",
|
||||
"ios": {
|
||||
"infoPlist": {
|
||||
"CFBundleURLTypes": [
|
||||
{
|
||||
"CFBundleURLSchemes": ["reactnativewebdemo"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### URL Format Differences
|
||||
|
||||
#### Standalone App
|
||||
|
||||
```
|
||||
reactnativewebdemo://verify?refreshToken=abc123...
|
||||
```
|
||||
|
||||
#### Expo Go Development
|
||||
|
||||
```
|
||||
exp://192.168.1.103:19000/--/verify?refreshToken=abc123...
|
||||
```
|
||||
|
||||
### Dynamic URL Generation
|
||||
|
||||
```typescript
|
||||
// Automatically creates correct URL format for current environment
|
||||
const redirectUrl = Linking.createURL("verify");
|
||||
|
||||
// In Expo Go: exp://192.168.1.103:19000/--/verify
|
||||
// In standalone: reactnativewebdemo://verify
|
||||
```
|
||||
|
||||
## Verification Endpoint
|
||||
|
||||
### Verify Screen Implementation
|
||||
|
||||
```typescript
|
||||
// app/verify.tsx
|
||||
export default function Verify() {
|
||||
const params = useLocalSearchParams<{ refreshToken: string }>();
|
||||
const [status, setStatus] = useState<"verifying" | "success" | "error">(
|
||||
"verifying",
|
||||
);
|
||||
const { nhost, isAuthenticated } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
const refreshToken = params.refreshToken;
|
||||
|
||||
if (!refreshToken) {
|
||||
setStatus("error");
|
||||
setError("No refresh token found in the link");
|
||||
return;
|
||||
}
|
||||
|
||||
async function processToken(): Promise<void> {
|
||||
try {
|
||||
// Brief delay to show verifying state
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
// Authenticate using the refresh token
|
||||
await nhost.auth.refreshToken({ refreshToken });
|
||||
|
||||
setStatus("success");
|
||||
|
||||
// Redirect to profile after brief success message
|
||||
setTimeout(() => {
|
||||
router.replace("/profile");
|
||||
}, 1500);
|
||||
} catch (err) {
|
||||
setStatus("error");
|
||||
setError(`Authentication failed: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
processToken();
|
||||
}, [params, nhost.auth]);
|
||||
|
||||
// ... UI implementation for different states
|
||||
}
|
||||
```
|
||||
|
||||
### Verification States
|
||||
|
||||
1. **Verifying**: Shows loading spinner while processing token
|
||||
2. **Success**: Displays success message before redirect
|
||||
3. **Error**: Shows error details and debugging information
|
||||
|
||||
## URL Parameter Handling
|
||||
|
||||
### Token Extraction
|
||||
|
||||
The verify screen extracts authentication parameters from the URL:
|
||||
|
||||
```typescript
|
||||
// Extract refresh token from URL parameters
|
||||
const params = useLocalSearchParams<{ refreshToken: string }>();
|
||||
const refreshToken = params.refreshToken;
|
||||
|
||||
// Validate token presence
|
||||
if (!refreshToken) {
|
||||
setStatus("error");
|
||||
setError("No refresh token found in the link");
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
### Debug Information
|
||||
|
||||
For development, the verify screen can display all received parameters:
|
||||
|
||||
```typescript
|
||||
// Debug: Show all URL parameters (development only)
|
||||
if (__DEV__) {
|
||||
const allParams: Record<string, string> = {};
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (typeof value === "string") {
|
||||
allParams[key] = value;
|
||||
}
|
||||
});
|
||||
console.log("Received URL parameters:", allParams);
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Magic Links
|
||||
|
||||
### Development with Expo Go
|
||||
|
||||
1. **Start Development Server**:
|
||||
|
||||
```bash
|
||||
npx expo start
|
||||
```
|
||||
|
||||
2. **Note Your Local URL**:
|
||||
|
||||
- Check terminal output for development URL (e.g., `exp://192.168.1.103:19000`)
|
||||
|
||||
3. **Send Magic Link**:
|
||||
|
||||
- Use Magic Link form in the app
|
||||
- Enter your email address
|
||||
- Submit the form
|
||||
|
||||
4. **Check Email Format**:
|
||||
|
||||
- Magic link should use format: `exp://192.168.1.103:19000/--/verify?refreshToken=...`
|
||||
- The `--` segment is crucial for Expo Go routing
|
||||
|
||||
5. **Test the Link**:
|
||||
- Open email on device with Expo Go installed
|
||||
- Tap the magic link
|
||||
- Should open directly in Expo Go
|
||||
|
||||
### Testing Strategies
|
||||
|
||||
#### Manual Testing
|
||||
|
||||
```typescript
|
||||
// Test different scenarios
|
||||
const testScenarios = [
|
||||
"Valid magic link with correct token",
|
||||
"Expired magic link",
|
||||
"Invalid refresh token",
|
||||
"Malformed URL parameters",
|
||||
"Network connectivity issues",
|
||||
"Already authenticated user",
|
||||
];
|
||||
```
|
||||
|
||||
#### Automated URL Testing
|
||||
|
||||
```typescript
|
||||
// Manually test URL handling
|
||||
const testUrls = [
|
||||
"exp://192.168.1.103:19000/--/verify?refreshToken=valid_token",
|
||||
"exp://192.168.1.103:19000/--/verify?refreshToken=invalid_token",
|
||||
"exp://192.168.1.103:19000/--/verify", // Missing token
|
||||
];
|
||||
```
|
||||
|
||||
## Environment-Specific Considerations
|
||||
|
||||
### Expo Go Limitations
|
||||
|
||||
1. **URL Format**: Must use `exp://` protocol with development server URL
|
||||
2. **Port Changes**: URL changes if development server restarts on different port
|
||||
3. **Network Dependency**: Requires same network for device and development machine
|
||||
4. **Debug Access**: Can inspect URL parameters more easily
|
||||
|
||||
### Standalone App Benefits
|
||||
|
||||
1. **Custom Scheme**: Uses app's custom URL scheme (`reactnativewebdemo://`)
|
||||
2. **Universal Links**: Can configure universal links for production
|
||||
3. **App Store Distribution**: Works with published apps
|
||||
4. **Offline Capability**: Less dependent on development server
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Token Security
|
||||
|
||||
1. **Short-Lived Tokens**: Refresh tokens have limited lifespan
|
||||
2. **Single Use**: Tokens are invalidated after successful authentication
|
||||
3. **Secure Transport**: Links are sent via secure email delivery
|
||||
4. **Validation**: Server-side token validation prevents tampering
|
||||
|
||||
### Best Practices
|
||||
|
||||
```typescript
|
||||
// Implement proper error handling
|
||||
const processToken = async (token: string) => {
|
||||
try {
|
||||
// Validate token format before sending to server
|
||||
if (!token || token.length < 10) {
|
||||
throw new Error("Invalid token format");
|
||||
}
|
||||
|
||||
// Use the token
|
||||
await nhost.auth.refreshToken({ refreshToken: token });
|
||||
} catch (error) {
|
||||
// Log error for debugging but don't expose details to user
|
||||
console.error("Magic link authentication failed:", error);
|
||||
throw new Error("Authentication failed. Please try again.");
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
| Issue | Symptom | Solution |
|
||||
| ---------------------------------- | --------------------------------------- | ------------------------------------------------------------- |
|
||||
| Link doesn't open app | Clicking link opens browser instead | Check URL scheme configuration and Expo Go installation |
|
||||
| "No refresh token" error | Link opens app but shows error | Verify email contains correct URL format with parameters |
|
||||
| Network errors during verification | Authentication fails with network error | Check Nhost configuration and internet connectivity |
|
||||
| Wrong URL format in email | Link uses incorrect protocol or format | Verify `Linking.createURL()` usage and development server URL |
|
||||
|
||||
### Debug Steps
|
||||
|
||||
1. **Check Console Logs**:
|
||||
|
||||
```typescript
|
||||
console.log("Generated redirect URL:", redirectUrl);
|
||||
console.log("Received URL parameters:", params);
|
||||
```
|
||||
|
||||
2. **Verify Email Content**:
|
||||
|
||||
- Check that email contains correct URL format
|
||||
- Ensure refresh token parameter is present
|
||||
|
||||
3. **Test URL Manually**:
|
||||
|
||||
- Copy magic link from email
|
||||
- Paste into browser or use device's URL handler
|
||||
|
||||
4. **Network Debugging**:
|
||||
- Ensure device and development machine are on same network
|
||||
- Check firewall settings
|
||||
|
||||
### Expo Go Specific Debugging
|
||||
|
||||
```typescript
|
||||
// Debug Expo Go URLs
|
||||
if (__DEV__) {
|
||||
const expoUrl = Linking.createURL("verify");
|
||||
console.log("Expo Go URL format:", expoUrl);
|
||||
|
||||
// Should output something like:
|
||||
// exp://192.168.1.103:19000/--/verify
|
||||
}
|
||||
```
|
||||
|
||||
## Production Deployment
|
||||
|
||||
### Universal Links (iOS)
|
||||
|
||||
For production iOS apps, configure universal links:
|
||||
|
||||
```json
|
||||
// apple-app-site-association
|
||||
{
|
||||
"applinks": {
|
||||
"apps": [],
|
||||
"details": [
|
||||
{
|
||||
"appID": "TEAMID.com.nhost.reactnativewebdemo",
|
||||
"paths": ["/verify*"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### App Links (Android)
|
||||
|
||||
Configure Android app links for seamless user experience:
|
||||
|
||||
```xml
|
||||
<!-- android/app/src/main/AndroidManifest.xml -->
|
||||
<activity android:name=".MainActivity">
|
||||
<intent-filter android:autoVerify="true">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="https"
|
||||
android:host="yourapp.com"
|
||||
android:pathPrefix="/verify" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
```
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Protected Routes & Email Auth](./README_PROTECTED_ROUTES.md)
|
||||
- [Native Authentication](./README_NATIVE_AUTHENTICATION.md)
|
||||
- [Social Sign-In](./README_SOCIAL_SIGNIN.md)
|
||||
|
||||
## External Resources
|
||||
|
||||
- [Expo Linking Documentation](https://docs.expo.dev/guides/linking/)
|
||||
- [React Navigation Deep Linking](https://reactnavigation.org/docs/deep-linking/)
|
||||
- [Nhost Passwordless Authentication](https://docs.nhost.io/authentication/passwordless)
|
||||
- [Universal Links (iOS)](https://developer.apple.com/ios/universal-links/)
|
||||
- [Android App Links](https://developer.android.com/training/app-links)
|
||||
@@ -1,381 +0,0 @@
|
||||
# Native Authentication - Apple Sign-In
|
||||
|
||||
This document explains how native authentication with Apple Sign-In is implemented in the Nhost React Native demo, including deep linking, nonce generation, ID tokens, and security considerations.
|
||||
|
||||
## Overview
|
||||
|
||||
Apple Sign-In provides a secure, privacy-focused authentication method for iOS users. The implementation uses cryptographic nonces, identity tokens, and deep linking to ensure a secure authentication flow between the app, Apple's servers, and Nhost.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Authentication Flow
|
||||
|
||||
1. **Nonce Generation**: Create a cryptographic nonce for request verification
|
||||
2. **Apple Authentication**: Request user authentication from Apple
|
||||
3. **Identity Token**: Receive signed JWT from Apple containing user information
|
||||
4. **Nhost Verification**: Send identity token and nonce to Nhost for verification
|
||||
5. **Session Creation**: Nhost validates the token and creates a user session
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Apple Sign-In Component
|
||||
|
||||
```typescript
|
||||
// app/components/AppleSignIn.tsx
|
||||
const AppleSignIn: React.FC<AppleSignInProps> = ({ setIsLoading }) => {
|
||||
const { nhost } = useAuth();
|
||||
const [appleAuthAvailable, setAppleAuthAvailable] = useState(false);
|
||||
|
||||
// Check Apple authentication availability
|
||||
useEffect(() => {
|
||||
const checkAvailability = async () => {
|
||||
if (Platform.OS === "ios") {
|
||||
const isAvailable = await AppleAuthentication.isAvailableAsync();
|
||||
setAppleAuthAvailable(isAvailable);
|
||||
}
|
||||
};
|
||||
checkAvailability();
|
||||
}, []);
|
||||
|
||||
const handleAppleSignIn = async () => {
|
||||
try {
|
||||
// Generate cryptographic nonce
|
||||
const nonce = Math.random().toString(36).substring(2, 15);
|
||||
const hashedNonce = await Crypto.digestStringAsync(
|
||||
Crypto.CryptoDigestAlgorithm.SHA256,
|
||||
nonce,
|
||||
);
|
||||
|
||||
// Request Apple authentication
|
||||
const credential = await AppleAuthentication.signInAsync({
|
||||
requestedScopes: [
|
||||
AppleAuthentication.AppleAuthenticationScope.FULL_NAME,
|
||||
AppleAuthentication.AppleAuthenticationScope.EMAIL,
|
||||
],
|
||||
nonce: hashedNonce,
|
||||
});
|
||||
|
||||
// Authenticate with Nhost
|
||||
if (credential.identityToken) {
|
||||
const response = await nhost.auth.signInIdToken({
|
||||
provider: "apple",
|
||||
idToken: credential.identityToken,
|
||||
nonce, // Original unhashed nonce
|
||||
});
|
||||
|
||||
if (response.body?.session) {
|
||||
router.replace("/profile");
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Handle authentication errors
|
||||
}
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
## Security Mechanisms
|
||||
|
||||
### Cryptographic Nonce
|
||||
|
||||
The nonce prevents replay attacks and ensures request authenticity:
|
||||
|
||||
```typescript
|
||||
// Generate random nonce
|
||||
const nonce = Math.random().toString(36).substring(2, 15);
|
||||
|
||||
// Hash nonce for Apple (SHA256)
|
||||
const hashedNonce = await Crypto.digestStringAsync(
|
||||
Crypto.CryptoDigestAlgorithm.SHA256,
|
||||
nonce,
|
||||
);
|
||||
|
||||
// Send hashed nonce to Apple
|
||||
const credential = await AppleAuthentication.signInAsync({
|
||||
nonce: hashedNonce,
|
||||
// ...
|
||||
});
|
||||
|
||||
// Send original nonce to Nhost for verification
|
||||
await nhost.auth.signInIdToken({
|
||||
provider: "apple",
|
||||
idToken: credential.identityToken,
|
||||
nonce, // Original unhashed nonce
|
||||
});
|
||||
```
|
||||
|
||||
### Why Nonce is Important
|
||||
|
||||
1. **Replay Attack Prevention**: Ensures each authentication request is unique
|
||||
2. **Request Binding**: Links the Apple response to the specific app request
|
||||
3. **Tampering Detection**: Detects if the response has been modified
|
||||
4. **Time-bound Security**: Nonces typically have short lifespans
|
||||
|
||||
### Identity Token Structure
|
||||
|
||||
Apple returns a JWT (JSON Web Token) containing:
|
||||
|
||||
```json
|
||||
{
|
||||
"iss": "https://appleid.apple.com",
|
||||
"aud": "com.nhost.reactnativewebdemo",
|
||||
"exp": 1634567890,
|
||||
"iat": 1634564290,
|
||||
"sub": "000123.abc456def789...",
|
||||
"nonce": "hashed_nonce_value",
|
||||
"email": "user@example.com",
|
||||
"email_verified": "true",
|
||||
"real_user_indicator": "true"
|
||||
}
|
||||
```
|
||||
|
||||
## Platform Requirements
|
||||
|
||||
### iOS Configuration
|
||||
|
||||
The app must be properly configured for Apple Sign-In:
|
||||
|
||||
```json
|
||||
// app.json
|
||||
{
|
||||
"expo": {
|
||||
"ios": {
|
||||
"bundleIdentifier": "com.nhost.reactnativewebdemo",
|
||||
"infoPlist": {
|
||||
"NSFaceIDUsageDescription": "This app uses Face ID for signing in"
|
||||
}
|
||||
},
|
||||
"plugins": ["expo-apple-authentication"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Availability Check
|
||||
|
||||
Apple Sign-In is only available on iOS 13+ devices:
|
||||
|
||||
```typescript
|
||||
const checkAvailability = async () => {
|
||||
if (Platform.OS === "ios") {
|
||||
const isAvailable = await AppleAuthentication.isAvailableAsync();
|
||||
setAppleAuthAvailable(isAvailable);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
## Nhost Configuration
|
||||
|
||||
### Apple Provider Setup
|
||||
|
||||
Configure Apple as an authentication provider in your Nhost dashboard:
|
||||
|
||||
1. **Team ID**: Your Apple Developer Team ID
|
||||
2. **Service ID**: Apple Services ID for your app
|
||||
3. **Key ID**: Apple Sign-In key identifier
|
||||
4. **Private Key**: Apple Sign-In private key (P8 file content)
|
||||
|
||||
### Server-Side Verification
|
||||
|
||||
Nhost performs server-side verification of the identity token:
|
||||
|
||||
1. **Signature Verification**: Validates JWT signature using Apple's public keys
|
||||
2. **Nonce Verification**: Compares hashed nonce in token with provided nonce
|
||||
3. **Audience Verification**: Ensures token is intended for your app
|
||||
4. **Expiration Check**: Validates token hasn't expired
|
||||
5. **Issuer Validation**: Confirms token comes from Apple
|
||||
|
||||
## Deep Linking Integration
|
||||
|
||||
### URL Scheme Configuration
|
||||
|
||||
The app is configured with custom URL schemes for deep linking:
|
||||
|
||||
```json
|
||||
// app.json
|
||||
{
|
||||
"expo": {
|
||||
"scheme": "reactnativewebdemo",
|
||||
"ios": {
|
||||
"infoPlist": {
|
||||
"CFBundleURLTypes": [
|
||||
{
|
||||
"CFBundleURLSchemes": ["reactnativewebdemo"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Handling Deep Links
|
||||
|
||||
While Apple Sign-In typically doesn't require custom deep linking (it's handled within the app), the configuration supports it for other authentication flows:
|
||||
|
||||
```typescript
|
||||
// app/verify.tsx - Used by other auth methods
|
||||
useEffect(() => {
|
||||
const subscription = Linking.addEventListener("url", handleDeepLink);
|
||||
return () => subscription?.remove();
|
||||
}, []);
|
||||
|
||||
const handleDeepLink = (event: { url: string }) => {
|
||||
// Handle incoming deep links from authentication providers
|
||||
};
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Common Apple Sign-In Errors
|
||||
|
||||
```typescript
|
||||
const handleAppleSignIn = async () => {
|
||||
try {
|
||||
// ... authentication logic
|
||||
} catch (error: any) {
|
||||
if (error.code === "ERR_CANCELED") {
|
||||
// User canceled authentication
|
||||
return;
|
||||
}
|
||||
|
||||
if (error.code === "ERR_INVALID_RESPONSE") {
|
||||
Alert.alert("Error", "Invalid response from Apple");
|
||||
return;
|
||||
}
|
||||
|
||||
if (error.code === "ERR_NOT_AVAILABLE") {
|
||||
Alert.alert("Error", "Apple Sign-In not available on this device");
|
||||
return;
|
||||
}
|
||||
|
||||
// Generic error handling
|
||||
Alert.alert("Authentication Error", error.message || "Unknown error");
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Nhost Integration Errors
|
||||
|
||||
```typescript
|
||||
const response = await nhost.auth.signInIdToken({
|
||||
provider: "apple",
|
||||
idToken: credential.identityToken,
|
||||
nonce,
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
switch (response.error.message) {
|
||||
case "Invalid identity token":
|
||||
Alert.alert("Error", "Authentication failed. Please try again.");
|
||||
break;
|
||||
case "Invalid nonce":
|
||||
Alert.alert("Error", "Security verification failed");
|
||||
break;
|
||||
default:
|
||||
Alert.alert("Error", "Authentication error occurred");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Privacy Features
|
||||
|
||||
### Apple's Privacy Protection
|
||||
|
||||
Apple Sign-In provides enhanced privacy features:
|
||||
|
||||
1. **Email Relay**: Apple can provide relay emails to protect user's real email
|
||||
2. **Minimal Data**: Only requests necessary user information
|
||||
3. **User Control**: Users can choose what information to share
|
||||
4. **Private Email**: Option to hide real email address
|
||||
|
||||
### Handling Private Emails
|
||||
|
||||
```typescript
|
||||
// Handle Apple's private relay emails
|
||||
const credential = await AppleAuthentication.signInAsync({
|
||||
requestedScopes: [
|
||||
AppleAuthentication.AppleAuthenticationScope.EMAIL,
|
||||
AppleAuthentication.AppleAuthenticationScope.FULL_NAME,
|
||||
],
|
||||
nonce: hashedNonce,
|
||||
});
|
||||
|
||||
// Email might be a private relay address
|
||||
console.log("Email:", credential.email); // Could be privaterelay@example.com
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### Development Testing
|
||||
|
||||
1. **iOS Simulator**: Apple Sign-In works in iOS Simulator (iOS 14+)
|
||||
2. **Physical Device**: Test on real iOS devices for complete functionality
|
||||
3. **Xcode Console**: Monitor authentication flow through Xcode logs
|
||||
|
||||
### Test Scenarios
|
||||
|
||||
```typescript
|
||||
// Test different authentication states
|
||||
const testScenarios = [
|
||||
"First-time sign in with Apple ID",
|
||||
"Returning user authentication",
|
||||
"User cancels authentication",
|
||||
"Network connection issues",
|
||||
"Invalid Apple ID credentials",
|
||||
"Apple ID with 2FA enabled",
|
||||
];
|
||||
```
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
### Implementation Guidelines
|
||||
|
||||
1. **Always Use Nonce**: Never skip nonce generation for production apps
|
||||
2. **Validate Server-Side**: Let Nhost handle token validation
|
||||
3. **Handle Errors Gracefully**: Provide clear feedback to users
|
||||
4. **Secure Storage**: Let Nhost handle session storage securely
|
||||
5. **Regular Updates**: Keep Apple authentication libraries updated
|
||||
|
||||
### Production Considerations
|
||||
|
||||
1. **Apple Developer Account**: Requires paid Apple Developer membership
|
||||
2. **App Store Review**: Apple Sign-In must be implemented if other social logins exist
|
||||
3. **Bundle ID Matching**: Ensure bundle ID matches Apple configuration
|
||||
4. **Certificate Management**: Keep Apple certificates and keys updated
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
| Issue | Cause | Solution |
|
||||
| ---------------------- | ---------------------------------------- | ------------------------------------------------- |
|
||||
| "Not Available" Error | iOS version < 13 or not configured | Check device compatibility and configuration |
|
||||
| Invalid Identity Token | Incorrect Nhost Apple configuration | Verify Apple provider settings in Nhost dashboard |
|
||||
| Nonce Mismatch | Sending hashed nonce to Nhost | Send original unhashed nonce to Nhost |
|
||||
| Bundle ID Mismatch | App bundle ID doesn't match Apple config | Ensure bundle IDs match in all configurations |
|
||||
|
||||
### Debug Tools
|
||||
|
||||
```typescript
|
||||
// Enable debug logging
|
||||
if (__DEV__) {
|
||||
console.log("Apple Auth Available:", appleAuthAvailable);
|
||||
console.log("Generated Nonce:", nonce);
|
||||
console.log("Hashed Nonce:", hashedNonce);
|
||||
console.log("Identity Token:", credential.identityToken);
|
||||
}
|
||||
```
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Apple Sign-In Setup Guide](./APPLE_SIGN_IN_SETUP.md)
|
||||
- [Protected Routes & Email Auth](./README_PROTECTED_ROUTES.md)
|
||||
- [Magic Links](./README_MAGIC_LINKS.md)
|
||||
- [Social Sign-In](./README_SOCIAL_SIGNIN.md)
|
||||
|
||||
## External Resources
|
||||
|
||||
- [Apple Sign-In Documentation](https://developer.apple.com/sign-in-with-apple/)
|
||||
- [Expo Apple Authentication](https://docs.expo.dev/versions/latest/sdk/apple-authentication/)
|
||||
- [Nhost Apple Provider Setup](https://docs.nhost.io/authentication/providers/apple)
|
||||
- [JWT Token Inspector](https://jwt.io/) - For debugging identity tokens
|
||||
@@ -1,357 +0,0 @@
|
||||
# Protected Routes & Email Authentication
|
||||
|
||||
This document explains how protected routes and email/password authentication are implemented in the Nhost React Native demo, including multi-factor authentication (MFA) support.
|
||||
|
||||
## Overview
|
||||
|
||||
The app implements a robust authentication system with:
|
||||
|
||||
- Email/password sign-up and sign-in
|
||||
- Route protection for authenticated users
|
||||
- Multi-factor authentication (MFA) with TOTP
|
||||
- Persistent session management
|
||||
- Automatic redirects for unauthenticated users
|
||||
|
||||
## Authentication Context
|
||||
|
||||
### AuthProvider Implementation
|
||||
|
||||
The `AuthProvider` component wraps the entire app and provides global authentication state:
|
||||
|
||||
```typescript
|
||||
// app/lib/nhost/AuthProvider.tsx
|
||||
const AuthProvider = ({ children }: AuthProviderProps) => {
|
||||
const [user, setUser] = useState<Session["user"] | null>(null);
|
||||
const [session, setSession] = useState<Session | null>(null);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(false);
|
||||
|
||||
const nhost = useMemo(() => {
|
||||
const subdomain =
|
||||
Constants.expoConfig?.extra?.["NHOST_SUBDOMAIN"] || "local";
|
||||
const region = Constants.expoConfig?.extra?.["NHOST_REGION"] || "local";
|
||||
|
||||
return createClient({
|
||||
subdomain,
|
||||
region,
|
||||
storage: new NhostAsyncStorage(), // Custom AsyncStorage adapter
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Session initialization and change listeners...
|
||||
};
|
||||
```
|
||||
|
||||
### Key Features
|
||||
|
||||
1. **Session Persistence**: Uses a custom AsyncStorage adapter that works with Nhost's synchronous interface
|
||||
2. **Automatic State Updates**: Listens for session changes and updates the global state
|
||||
3. **Loading States**: Manages loading states during authentication operations
|
||||
4. **Error Handling**: Graceful handling of storage and authentication errors
|
||||
|
||||
## Protected Routes
|
||||
|
||||
### ProtectedScreen Component
|
||||
|
||||
The `ProtectedScreen` component acts as a higher-order component that protects routes:
|
||||
|
||||
```typescript
|
||||
// app/components/ProtectedScreen.tsx
|
||||
export default function ProtectedScreen({
|
||||
children,
|
||||
redirectTo = "/signin",
|
||||
}: ProtectedScreenProps) {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isAuthenticated) {
|
||||
router.replace(redirectTo);
|
||||
}
|
||||
}, [isAuthenticated, isLoading, redirectTo]);
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return null; // Will redirect in useEffect
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
```
|
||||
|
||||
### Usage Example
|
||||
|
||||
Protect any screen by wrapping it with `ProtectedScreen`:
|
||||
|
||||
```typescript
|
||||
// app/profile.tsx
|
||||
export default function Profile() {
|
||||
return (
|
||||
<ProtectedScreen>
|
||||
<ProfileContent />
|
||||
</ProtectedScreen>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Features
|
||||
|
||||
1. **Automatic Redirects**: Unauthenticated users are redirected to sign-in
|
||||
2. **Loading States**: Shows loading indicator while checking authentication
|
||||
3. **Customizable Redirect**: Can specify where to redirect unauthenticated users
|
||||
4. **No Flash**: Prevents showing protected content before redirect
|
||||
|
||||
## Email/Password Authentication
|
||||
|
||||
### Sign Up Flow
|
||||
|
||||
```typescript
|
||||
// User registration with email and password
|
||||
const handleSignUp = async () => {
|
||||
const { error } = await nhost.auth.signUp({
|
||||
email,
|
||||
password,
|
||||
options: {
|
||||
displayName,
|
||||
},
|
||||
});
|
||||
|
||||
if (!error) {
|
||||
// User created successfully
|
||||
router.replace("/profile");
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Sign In Flow
|
||||
|
||||
```typescript
|
||||
// User authentication with email and password
|
||||
const handleSignIn = async () => {
|
||||
const { error, needsEmailVerification, needsMfaOtp } =
|
||||
await nhost.auth.signInEmailPassword({
|
||||
email,
|
||||
password,
|
||||
});
|
||||
|
||||
if (needsEmailVerification) {
|
||||
setError("Please verify your email before signing in");
|
||||
return;
|
||||
}
|
||||
|
||||
if (needsMfaOtp) {
|
||||
// Redirect to MFA input screen
|
||||
setShowMfaInput(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!error) {
|
||||
router.replace("/profile");
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
## Multi-Factor Authentication (MFA)
|
||||
|
||||
### TOTP Setup
|
||||
|
||||
The app supports Time-based One-Time Password (TOTP) authentication:
|
||||
|
||||
```typescript
|
||||
// Generate TOTP secret and QR code
|
||||
const generateMfa = async () => {
|
||||
const { totpSecret, qrCodeDataUrl } = await nhost.auth.generateMfa();
|
||||
|
||||
// Display QR code for user to scan with authenticator app
|
||||
setQrCode(qrCodeDataUrl);
|
||||
setTotpSecret(totpSecret);
|
||||
};
|
||||
```
|
||||
|
||||
### MFA Verification
|
||||
|
||||
```typescript
|
||||
// Verify TOTP code during sign-in
|
||||
const verifyMfaCode = async () => {
|
||||
const { error } = await nhost.auth.signInMfaTotp({
|
||||
otp: mfaCode,
|
||||
});
|
||||
|
||||
if (!error) {
|
||||
router.replace("/profile");
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### MFA Management
|
||||
|
||||
Users can enable/disable MFA from their profile:
|
||||
|
||||
```typescript
|
||||
// Enable MFA with TOTP
|
||||
const enableMfa = async () => {
|
||||
const { error } = await nhost.auth.enableMfa({
|
||||
code: totpCode,
|
||||
});
|
||||
};
|
||||
|
||||
// Disable MFA
|
||||
const disableMfa = async () => {
|
||||
const { error } = await nhost.auth.disableMfa({
|
||||
code: totpCode,
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
## Session Management
|
||||
|
||||
### Custom AsyncStorage Adapter
|
||||
|
||||
The app uses a custom storage adapter for reliable session persistence:
|
||||
|
||||
```typescript
|
||||
// app/lib/nhost/AsyncStorage.tsx
|
||||
export default class NhostAsyncStorage implements Storage {
|
||||
private cache: Map<string, string> = new Map();
|
||||
|
||||
setItem(key: string, value: string): void {
|
||||
this.cache.set(key, value);
|
||||
AsyncStorage.setItem(key, value).catch(console.error);
|
||||
}
|
||||
|
||||
getItem(key: string): string | null {
|
||||
return this.cache.get(key) || null;
|
||||
}
|
||||
|
||||
removeItem(key: string): void {
|
||||
this.cache.delete(key);
|
||||
AsyncStorage.removeItem(key).catch(console.error);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Features
|
||||
|
||||
1. **In-Memory Cache**: Provides synchronous access for Nhost while using AsyncStorage
|
||||
2. **Persistence**: Sessions survive app restarts and background/foreground cycles
|
||||
3. **Error Handling**: Graceful fallback if AsyncStorage operations fail
|
||||
4. **Expo Go Compatible**: Works reliably in both Expo Go and standalone builds
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Common Authentication Errors
|
||||
|
||||
```typescript
|
||||
const handleAuthError = (error: any) => {
|
||||
switch (error?.message) {
|
||||
case "Invalid email or password":
|
||||
setError("Please check your email and password");
|
||||
break;
|
||||
case "Email not verified":
|
||||
setError("Please verify your email before signing in");
|
||||
break;
|
||||
case "Invalid MFA code":
|
||||
setError("Please enter a valid 6-digit code");
|
||||
break;
|
||||
default:
|
||||
setError("An unexpected error occurred");
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Network and Storage Errors
|
||||
|
||||
The app handles various error scenarios:
|
||||
|
||||
- Network connectivity issues
|
||||
- AsyncStorage failures
|
||||
- Nhost service unavailability
|
||||
- Invalid authentication tokens
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Best Practices Implemented
|
||||
|
||||
1. **Secure Storage**: Sessions are stored securely using AsyncStorage
|
||||
2. **Token Validation**: Automatic token refresh and validation
|
||||
3. **Route Protection**: Server-side validation of protected routes
|
||||
4. **MFA Support**: Additional security layer with TOTP
|
||||
5. **Session Expiry**: Automatic logout when sessions expire
|
||||
|
||||
### Password Requirements
|
||||
|
||||
Configure password requirements in your Nhost dashboard:
|
||||
|
||||
- Minimum length
|
||||
- Character complexity
|
||||
- Common password prevention
|
||||
- Breach database checking
|
||||
|
||||
## Testing Authentication
|
||||
|
||||
### Test Scenarios
|
||||
|
||||
1. **Valid Credentials**: Test successful sign-in with correct email/password
|
||||
2. **Invalid Credentials**: Test error handling with wrong credentials
|
||||
3. **Unverified Email**: Test flow for users who haven't verified email
|
||||
4. **MFA Flow**: Test sign-in with MFA enabled
|
||||
5. **Session Persistence**: Test app restart with active session
|
||||
6. **Network Errors**: Test offline scenarios and poor connectivity
|
||||
|
||||
### Debug Tools
|
||||
|
||||
Enable debug mode to see authentication state changes:
|
||||
|
||||
```typescript
|
||||
// Add to AuthProvider for debugging
|
||||
useEffect(() => {
|
||||
if (__DEV__) {
|
||||
console.log("Auth state changed:", { isAuthenticated, user: user?.email });
|
||||
}
|
||||
}, [isAuthenticated, user]);
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Required Setup
|
||||
|
||||
1. **Email Provider**: Configure email provider in Nhost dashboard
|
||||
2. **Email Templates**: Customize verification and welcome emails
|
||||
3. **Password Policy**: Set password requirements
|
||||
4. **MFA Settings**: Enable TOTP in authentication settings
|
||||
|
||||
### Environment Variables
|
||||
|
||||
```json
|
||||
// app.json
|
||||
{
|
||||
"extra": {
|
||||
"NHOST_SUBDOMAIN": "your-project-subdomain",
|
||||
"NHOST_REGION": "your-region"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Session Not Persisting**: Check AsyncStorage permissions and implementation
|
||||
2. **Infinite Loading**: Verify Nhost configuration and network connectivity
|
||||
3. **MFA Not Working**: Ensure time synchronization between device and server
|
||||
4. **Redirect Loops**: Check protected route logic and authentication state
|
||||
|
||||
### Debug Steps
|
||||
|
||||
1. Check console logs for authentication errors
|
||||
2. Verify Nhost dashboard configuration
|
||||
3. Test with simple email/password flow first
|
||||
4. Gradually add complexity (MFA, protected routes)
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Native Authentication](./README_NATIVE_AUTHENTICATION.md)
|
||||
- [Magic Links](./README_MAGIC_LINKS.md)
|
||||
- [Social Sign-In](./README_SOCIAL_SIGNIN.md)
|
||||
@@ -1,530 +0,0 @@
|
||||
# Social Sign-In with GitHub
|
||||
|
||||
This document explains how social authentication with GitHub is implemented in the Nhost React Native demo, including OAuth flow, deep linking, verification endpoints, and configuration requirements.
|
||||
|
||||
## Overview
|
||||
|
||||
Social sign-in with GitHub provides users with a seamless authentication experience using their existing GitHub accounts. The implementation handles OAuth 2.0 flow, deep linking for mobile apps, and secure token exchange through Nhost's authentication system.
|
||||
|
||||
## OAuth 2.0 Flow
|
||||
|
||||
### Authentication Process
|
||||
|
||||
1. **OAuth Initiation**: App redirects user to GitHub OAuth page
|
||||
2. **User Authorization**: User grants permissions to the app on GitHub
|
||||
3. **Authorization Code**: GitHub redirects back with authorization code
|
||||
4. **Token Exchange**: Nhost exchanges code for access token
|
||||
5. **User Profile**: Nhost fetches user profile from GitHub
|
||||
6. **Session Creation**: Nhost creates authenticated session for the user
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### SocialLoginForm Component
|
||||
|
||||
```typescript
|
||||
// app/components/SocialLoginForm.tsx
|
||||
export default function SocialLoginForm({
|
||||
action,
|
||||
isLoading: initialLoading = false,
|
||||
}: SocialLoginFormProps) {
|
||||
const { nhost } = useAuth();
|
||||
const [isLoading] = useState(initialLoading);
|
||||
|
||||
const handleSocialLogin = (provider: "github") => {
|
||||
// Create redirect URL for current environment
|
||||
const redirectUrl = Linking.createURL("verify");
|
||||
|
||||
// Generate OAuth URL with provider and redirect
|
||||
const url = nhost.auth.signInProviderURL(provider, {
|
||||
redirectTo: redirectUrl,
|
||||
});
|
||||
|
||||
// Open GitHub OAuth in system browser
|
||||
void Linking.openURL(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.socialContainer}>
|
||||
<Text style={styles.socialText}>
|
||||
{action} using your Social account
|
||||
</Text>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.socialButton}
|
||||
onPress={() => handleSocialLogin("github")}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<View style={styles.buttonContent}>
|
||||
<Ionicons name="logo-github" size={22} style={styles.githubIcon} />
|
||||
<Text style={styles.socialButtonText}>Continue with GitHub</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Key Features
|
||||
|
||||
1. **Dynamic URL Generation**: Automatically creates correct redirect URLs for different environments
|
||||
2. **Provider Flexibility**: Easily extensible to support additional OAuth providers
|
||||
3. **Visual Feedback**: Loading states and branded buttons for better UX
|
||||
4. **Error Handling**: Graceful handling of OAuth failures and cancellations
|
||||
|
||||
## Deep Linking Configuration
|
||||
|
||||
### URL Scheme Setup
|
||||
|
||||
The app supports deep linking to handle OAuth redirects:
|
||||
|
||||
```json
|
||||
// app.json
|
||||
{
|
||||
"expo": {
|
||||
"scheme": "reactnativewebdemo",
|
||||
"ios": {
|
||||
"infoPlist": {
|
||||
"CFBundleURLTypes": [
|
||||
{
|
||||
"CFBundleURLSchemes": ["reactnativewebdemo"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Redirect URL Formats
|
||||
|
||||
#### Standalone App
|
||||
|
||||
```
|
||||
reactnativewebdemo://verify?refreshToken=abc123...&type=signup
|
||||
```
|
||||
|
||||
#### Expo Go Development
|
||||
|
||||
```
|
||||
exp://192.168.1.103:19000/--/verify?refreshToken=abc123...&type=signup
|
||||
```
|
||||
|
||||
### Environment-Aware URL Generation
|
||||
|
||||
```typescript
|
||||
// Automatically handles environment differences
|
||||
const redirectUrl = Linking.createURL("verify");
|
||||
|
||||
// Creates appropriate URL for current environment:
|
||||
// - Expo Go: exp://host:port/--/verify
|
||||
// - Standalone: reactnativewebdemo://verify
|
||||
```
|
||||
|
||||
## GitHub OAuth Configuration
|
||||
|
||||
### Nhost Dashboard Setup
|
||||
|
||||
Configure GitHub as an OAuth provider in your Nhost dashboard:
|
||||
|
||||
1. **Provider**: Enable GitHub in Authentication > Providers
|
||||
2. **Client ID**: GitHub OAuth App Client ID
|
||||
3. **Client Secret**: GitHub OAuth App Client Secret
|
||||
4. **Redirect URL**: Configure allowed redirect URLs
|
||||
|
||||
### GitHub OAuth App Setup
|
||||
|
||||
Create a GitHub OAuth App in your GitHub Developer Settings:
|
||||
|
||||
1. **Application Name**: Your app name
|
||||
2. **Homepage URL**: Your app's homepage
|
||||
3. **Authorization Callback URL**:
|
||||
- Development: `https://local.auth.nhost.run/v1/auth/providers/github/callback`
|
||||
- Production: `https://[subdomain].auth.[region].nhost.run/v1/auth/providers/github/callback`
|
||||
|
||||
### Required GitHub Scopes
|
||||
|
||||
The app requests these GitHub scopes:
|
||||
|
||||
- `user:email` - Access to user's email addresses
|
||||
- `read:user` - Access to user profile information
|
||||
|
||||
## Verification Flow
|
||||
|
||||
### Verify Screen Implementation
|
||||
|
||||
```typescript
|
||||
// app/verify.tsx - Handles both magic links and social auth
|
||||
export default function Verify() {
|
||||
const params = useLocalSearchParams<{
|
||||
refreshToken: string;
|
||||
type?: string;
|
||||
}>();
|
||||
const [status, setStatus] = useState<"verifying" | "success" | "error">(
|
||||
"verifying",
|
||||
);
|
||||
const { nhost, isAuthenticated } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
const { refreshToken, type } = params;
|
||||
|
||||
if (!refreshToken) {
|
||||
setStatus("error");
|
||||
setError("No authentication token found");
|
||||
return;
|
||||
}
|
||||
|
||||
async function processAuthentication(): Promise<void> {
|
||||
try {
|
||||
// Show verifying state briefly
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
// Authenticate using refresh token from OAuth flow
|
||||
await nhost.auth.refreshToken({ refreshToken });
|
||||
|
||||
setStatus("success");
|
||||
|
||||
// Redirect after showing success message
|
||||
setTimeout(() => {
|
||||
router.replace("/profile");
|
||||
}, 1500);
|
||||
} catch (err) {
|
||||
setStatus("error");
|
||||
setError(`Authentication failed: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
processAuthentication();
|
||||
}, [params, nhost.auth]);
|
||||
|
||||
// Redirect if already authenticated
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && status !== "verifying") {
|
||||
router.replace("/profile");
|
||||
}
|
||||
}, [isAuthenticated, status]);
|
||||
|
||||
// ... UI implementation for different states
|
||||
}
|
||||
```
|
||||
|
||||
### Authentication States
|
||||
|
||||
1. **Verifying**: Processing OAuth callback and exchanging tokens
|
||||
2. **Success**: Authentication completed successfully
|
||||
3. **Error**: OAuth flow failed or was cancelled
|
||||
|
||||
## User Data Handling
|
||||
|
||||
### GitHub Profile Information
|
||||
|
||||
When users authenticate with GitHub, Nhost receives:
|
||||
|
||||
```typescript
|
||||
// User profile data from GitHub
|
||||
interface GitHubUser {
|
||||
id: string;
|
||||
email: string;
|
||||
displayName: string;
|
||||
avatarUrl?: string;
|
||||
metadata: {
|
||||
github: {
|
||||
id: number;
|
||||
login: string;
|
||||
name: string;
|
||||
company?: string;
|
||||
blog?: string;
|
||||
location?: string;
|
||||
bio?: string;
|
||||
public_repos: number;
|
||||
followers: number;
|
||||
following: number;
|
||||
created_at: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Accessing User Data
|
||||
|
||||
```typescript
|
||||
// Access GitHub-specific user data
|
||||
const { user } = useAuth();
|
||||
|
||||
if (user?.metadata?.github) {
|
||||
const githubData = user.metadata.github;
|
||||
console.log("GitHub username:", githubData.login);
|
||||
console.log("Public repos:", githubData.public_repos);
|
||||
console.log("Followers:", githubData.followers);
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### OAuth Flow Errors
|
||||
|
||||
```typescript
|
||||
// Common OAuth error scenarios
|
||||
const handleOAuthErrors = (error: any) => {
|
||||
switch (error.type) {
|
||||
case "access_denied":
|
||||
// User denied permission
|
||||
Alert.alert("Access Denied", "You need to grant permission to continue");
|
||||
break;
|
||||
|
||||
case "invalid_request":
|
||||
// Malformed OAuth request
|
||||
Alert.alert("Error", "Invalid authentication request");
|
||||
break;
|
||||
|
||||
case "server_error":
|
||||
// GitHub server error
|
||||
Alert.alert("Error", "GitHub is temporarily unavailable");
|
||||
break;
|
||||
|
||||
case "temporarily_unavailable":
|
||||
// Service temporarily unavailable
|
||||
Alert.alert("Error", "Authentication service is busy. Please try again.");
|
||||
break;
|
||||
|
||||
default:
|
||||
Alert.alert("Error", "Authentication failed. Please try again.");
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Network and Integration Errors
|
||||
|
||||
```typescript
|
||||
// Handle Nhost integration errors
|
||||
const processOAuthCallback = async (refreshToken: string) => {
|
||||
try {
|
||||
await nhost.auth.refreshToken({ refreshToken });
|
||||
} catch (error) {
|
||||
if (error.message.includes("Invalid refresh token")) {
|
||||
throw new Error("Authentication session expired. Please try again.");
|
||||
}
|
||||
|
||||
if (error.message.includes("Network")) {
|
||||
throw new Error("Network error. Please check your connection.");
|
||||
}
|
||||
|
||||
throw new Error("Authentication failed. Please try again.");
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
## Testing Social Authentication
|
||||
|
||||
### Development Testing
|
||||
|
||||
1. **Start Development Server**:
|
||||
|
||||
```bash
|
||||
npx expo start
|
||||
```
|
||||
|
||||
2. **Configure Test Environment**:
|
||||
|
||||
- Ensure GitHub OAuth app has correct callback URL
|
||||
- Verify Nhost GitHub provider configuration
|
||||
- Check network connectivity between device and development server
|
||||
|
||||
3. **Test OAuth Flow**:
|
||||
- Tap "Continue with GitHub" button
|
||||
- Should open system browser with GitHub OAuth page
|
||||
- Log in with GitHub credentials
|
||||
- Grant permissions to the app
|
||||
- Should redirect back to app and authenticate
|
||||
|
||||
### Test Scenarios
|
||||
|
||||
```typescript
|
||||
// Test different OAuth scenarios
|
||||
const testScenarios = [
|
||||
"First-time GitHub authentication",
|
||||
"Returning GitHub user",
|
||||
"User cancels OAuth flow",
|
||||
"User denies permissions",
|
||||
"GitHub account with 2FA enabled",
|
||||
"Network connection issues during OAuth",
|
||||
"Invalid OAuth configuration",
|
||||
"Expired OAuth session",
|
||||
];
|
||||
```
|
||||
|
||||
### Manual Testing Checklist
|
||||
|
||||
- [ ] GitHub OAuth button appears and is clickable
|
||||
- [ ] Clicking button opens system browser
|
||||
- [ ] GitHub login page loads correctly
|
||||
- [ ] Successfully logging in redirects back to app
|
||||
- [ ] App shows verification screen briefly
|
||||
- [ ] User is redirected to profile after authentication
|
||||
- [ ] User data is correctly populated from GitHub
|
||||
- [ ] Canceling OAuth flow handles gracefully
|
||||
- [ ] Network errors are handled appropriately
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### OAuth Security Best Practices
|
||||
|
||||
1. **HTTPS Only**: All OAuth URLs use HTTPS for secure communication
|
||||
2. **State Parameter**: Nhost includes state parameter to prevent CSRF attacks
|
||||
3. **Short-Lived Tokens**: Authorization codes have short expiration times
|
||||
4. **Secure Storage**: Refresh tokens are stored securely by Nhost
|
||||
5. **Scope Limitation**: Only request necessary permissions from GitHub
|
||||
|
||||
### Token Security
|
||||
|
||||
```typescript
|
||||
// Nhost handles secure token management
|
||||
// - Authorization codes are exchanged server-side
|
||||
// - Access tokens are not exposed to client
|
||||
// - Refresh tokens are securely stored
|
||||
// - Session management is handled automatically
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
| Issue | Symptom | Solution |
|
||||
| ---------------------------- | --------------------------------------- | ---------------------------------------------------------------- |
|
||||
| OAuth redirect doesn't work | Browser opens but doesn't return to app | Check URL scheme configuration and GitHub OAuth app callback URL |
|
||||
| "Invalid client" error | GitHub shows OAuth error page | Verify GitHub OAuth app Client ID in Nhost dashboard |
|
||||
| "Redirect URI mismatch" | GitHub rejects OAuth request | Ensure callback URL in GitHub app matches Nhost configuration |
|
||||
| App doesn't open after OAuth | Browser stays open after GitHub login | Check deep linking configuration and app installation |
|
||||
|
||||
### Debug Steps
|
||||
|
||||
1. **Check OAuth URL**:
|
||||
|
||||
```typescript
|
||||
const url = nhost.auth.signInProviderURL("github", {
|
||||
redirectTo: redirectUrl,
|
||||
});
|
||||
console.log("OAuth URL:", url);
|
||||
```
|
||||
|
||||
2. **Verify Redirect URL**:
|
||||
|
||||
```typescript
|
||||
const redirectUrl = Linking.createURL("verify");
|
||||
console.log("Redirect URL:", redirectUrl);
|
||||
```
|
||||
|
||||
3. **Check URL Parameters**:
|
||||
|
||||
```typescript
|
||||
// In verify screen
|
||||
console.log("Received parameters:", params);
|
||||
```
|
||||
|
||||
4. **Test Manual URL**:
|
||||
- Copy OAuth URL from console
|
||||
- Paste into browser to test flow manually
|
||||
|
||||
### GitHub-Specific Debugging
|
||||
|
||||
1. **Check GitHub OAuth App Settings**:
|
||||
|
||||
- Verify callback URLs are correctly configured
|
||||
- Ensure app is not suspended or restricted
|
||||
|
||||
2. **Monitor GitHub OAuth Logs**:
|
||||
|
||||
- Check GitHub OAuth app's activity logs
|
||||
- Look for failed authorization attempts
|
||||
|
||||
3. **Validate GitHub Scopes**:
|
||||
- Ensure requested scopes match app requirements
|
||||
- Check if user has granted necessary permissions
|
||||
|
||||
## Production Deployment
|
||||
|
||||
### GitHub OAuth App Configuration
|
||||
|
||||
For production deployment:
|
||||
|
||||
1. **Production Callback URL**:
|
||||
|
||||
```
|
||||
https://[subdomain].auth.[region].nhost.run/v1/auth/providers/github/callback
|
||||
```
|
||||
|
||||
2. **Homepage URL**: Set to your production app's homepage
|
||||
|
||||
3. **Application Description**: Provide clear description of your app's purpose
|
||||
|
||||
### Universal Links Setup
|
||||
|
||||
Configure universal links for seamless production experience:
|
||||
|
||||
```json
|
||||
// apple-app-site-association (iOS)
|
||||
{
|
||||
"applinks": {
|
||||
"apps": [],
|
||||
"details": [
|
||||
{
|
||||
"appID": "TEAMID.com.nhost.reactnativewebdemo",
|
||||
"paths": ["/verify*", "/auth/callback*"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Security Hardening
|
||||
|
||||
1. **Environment Variables**: Store sensitive OAuth credentials securely
|
||||
2. **Domain Validation**: Implement additional domain validation for callbacks
|
||||
3. **Rate Limiting**: Configure rate limiting for OAuth endpoints
|
||||
4. **Monitoring**: Set up monitoring for failed OAuth attempts
|
||||
|
||||
## Extending to Other Providers
|
||||
|
||||
### Adding New OAuth Providers
|
||||
|
||||
The implementation can be easily extended to support other providers:
|
||||
|
||||
```typescript
|
||||
// Extended provider support
|
||||
type SocialProvider = "github" | "google" | "facebook" | "discord";
|
||||
|
||||
const handleSocialLogin = (provider: SocialProvider) => {
|
||||
const redirectUrl = Linking.createURL("verify");
|
||||
const url = nhost.auth.signInProviderURL(provider, {
|
||||
redirectTo: redirectUrl,
|
||||
});
|
||||
void Linking.openURL(url);
|
||||
};
|
||||
|
||||
// Provider-specific UI
|
||||
const getProviderIcon = (provider: SocialProvider) => {
|
||||
switch (provider) {
|
||||
case "github":
|
||||
return "logo-github";
|
||||
case "google":
|
||||
return "logo-google";
|
||||
case "facebook":
|
||||
return "logo-facebook";
|
||||
case "discord":
|
||||
return "logo-discord";
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Protected Routes & Email Auth](./README_PROTECTED_ROUTES.md)
|
||||
- [Native Authentication](./README_NATIVE_AUTHENTICATION.md)
|
||||
- [Magic Links](./README_MAGIC_LINKS.md)
|
||||
|
||||
## External Resources
|
||||
|
||||
- [GitHub OAuth Documentation](https://docs.github.com/en/developers/apps/building-oauth-apps)
|
||||
- [Nhost Social Authentication](https://docs.nhost.io/authentication/social-login)
|
||||
- [OAuth 2.0 Security Best Practices](https://tools.ietf.org/html/draft-ietf-oauth-security-topics)
|
||||
- [Expo AuthSession](https://docs.expo.dev/versions/latest/sdk/auth-session/)
|
||||
- [React Native Deep Linking](https://reactnative.dev/docs/linking)
|
||||
@@ -1,53 +0,0 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "ReactNativeWebDemo",
|
||||
"slug": "ReactNativeWebDemo",
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/images/icon.png",
|
||||
"scheme": "reactnativewebdemo",
|
||||
"userInterfaceStyle": "automatic",
|
||||
"newArchEnabled": true,
|
||||
"ios": {
|
||||
"supportsTablet": true,
|
||||
"bundleIdentifier": "com.nhost.reactnativewebdemo",
|
||||
"jsEngine": "jsc",
|
||||
"infoPlist": {
|
||||
"NSFaceIDUsageDescription": "This app uses Face ID for signing in",
|
||||
"CFBundleURLTypes": [
|
||||
{
|
||||
"CFBundleURLSchemes": ["reactnativewebdemo"]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"android": {
|
||||
"jsEngine": "jsc",
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/images/adaptive-icon.png",
|
||||
"backgroundColor": "#ffffff"
|
||||
},
|
||||
"edgeToEdgeEnabled": true
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
[
|
||||
"expo-splash-screen",
|
||||
{
|
||||
"image": "./assets/images/splash-icon.png",
|
||||
"imageWidth": 200,
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#ffffff"
|
||||
}
|
||||
],
|
||||
["expo-apple-authentication"]
|
||||
],
|
||||
"experiments": {
|
||||
"typedRoutes": true
|
||||
},
|
||||
"extra": {
|
||||
"NHOST_REGION": "local",
|
||||
"NHOST_SUBDOMAIN": "192-168-1-103"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import { Stack } from "expo-router";
|
||||
import { Text, View } from "react-native";
|
||||
import { AuthProvider } from "./lib/nhost/AuthProvider";
|
||||
|
||||
export default function RootLayout() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerStyle: {
|
||||
backgroundColor: "#f5f5f5",
|
||||
},
|
||||
headerTintColor: "#333",
|
||||
headerTitleStyle: {
|
||||
fontWeight: "bold",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack.Screen name="index" options={{ title: "Home" }} />
|
||||
<Stack.Screen name="signin" options={{ title: "Sign In" }} />
|
||||
<Stack.Screen
|
||||
name="signin/mfa"
|
||||
options={{ title: "MFA Verification" }}
|
||||
/>
|
||||
<Stack.Screen name="signup" options={{ title: "Sign Up" }} />
|
||||
<Stack.Screen name="profile" options={{ title: "Profile" }} />
|
||||
<Stack.Screen name="upload" options={{ title: "File Upload" }} />
|
||||
<Stack.Screen name="verify" options={{ title: "Verify Email" }} />
|
||||
</Stack>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
// Error boundary to catch and display errors
|
||||
export function ErrorBoundary(props: { error: Error }) {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
padding: 20,
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: 18, fontWeight: "bold", marginBottom: 10 }}>
|
||||
An error occurred
|
||||
</Text>
|
||||
<Text style={{ color: "red", marginBottom: 10 }}>
|
||||
{props.error.message}
|
||||
</Text>
|
||||
<Text>{props.error.stack}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
import * as AppleAuthentication from "expo-apple-authentication";
|
||||
import * as Crypto from "expo-crypto";
|
||||
import { router } from "expo-router";
|
||||
import React from "react";
|
||||
import { Alert, Platform, StyleSheet } from "react-native";
|
||||
import { useAuth } from "../lib/nhost/AuthProvider";
|
||||
|
||||
interface AppleSignInProps {
|
||||
action: "Sign In" | "Sign Up";
|
||||
isLoading: boolean;
|
||||
setIsLoading: (isLoading: boolean) => void;
|
||||
}
|
||||
|
||||
const AppleSignIn: React.FC<AppleSignInProps> = ({ setIsLoading }) => {
|
||||
const { nhost } = useAuth();
|
||||
|
||||
// Check if Apple authentication is available on this device
|
||||
const [appleAuthAvailable, setAppleAuthAvailable] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
const checkAvailability = async () => {
|
||||
if (Platform.OS === "ios") {
|
||||
const isAvailable = await AppleAuthentication.isAvailableAsync();
|
||||
setAppleAuthAvailable(isAvailable);
|
||||
}
|
||||
};
|
||||
|
||||
void checkAvailability();
|
||||
}, []);
|
||||
|
||||
const handleAppleSignIn = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
const nonce = Math.random().toString(36).substring(2, 15);
|
||||
|
||||
// Hash the nonce for Apple Authentication
|
||||
const hashedNonce = await Crypto.digestStringAsync(
|
||||
Crypto.CryptoDigestAlgorithm.SHA256,
|
||||
nonce,
|
||||
);
|
||||
|
||||
// Request Apple authentication with our hashed nonce
|
||||
const credential = await AppleAuthentication.signInAsync({
|
||||
requestedScopes: [
|
||||
AppleAuthentication.AppleAuthenticationScope.FULL_NAME,
|
||||
AppleAuthentication.AppleAuthenticationScope.EMAIL,
|
||||
],
|
||||
nonce: hashedNonce,
|
||||
});
|
||||
|
||||
if (credential.identityToken) {
|
||||
// Use the identity token to sign in with Nhost
|
||||
// Pass the original unhashed nonce to the SDK
|
||||
// so the server can verify it
|
||||
const response = await nhost.auth.signInIdToken({
|
||||
provider: "apple",
|
||||
idToken: credential.identityToken,
|
||||
nonce,
|
||||
});
|
||||
|
||||
if (response.body?.session) {
|
||||
router.replace("/profile");
|
||||
} else {
|
||||
Alert.alert(
|
||||
"Authentication Error",
|
||||
"Failed to authenticate with Nhost",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
Alert.alert(
|
||||
"Authentication Error",
|
||||
"No identity token received from Apple",
|
||||
);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
// Handle other errors
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to authentica with Apple";
|
||||
Alert.alert("Authentication Error", message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Only show the button on iOS devices where Apple authentication is available
|
||||
if (Platform.OS !== "ios" || !appleAuthAvailable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<AppleAuthentication.AppleAuthenticationButton
|
||||
buttonType={AppleAuthentication.AppleAuthenticationButtonType.SIGN_IN}
|
||||
buttonStyle={AppleAuthentication.AppleAuthenticationButtonStyle.BLACK}
|
||||
cornerRadius={5}
|
||||
style={styles.appleButton}
|
||||
onPress={handleAppleSignIn}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
appleButton: {
|
||||
width: "100%",
|
||||
height: 45,
|
||||
marginBottom: 10,
|
||||
},
|
||||
});
|
||||
|
||||
export default AppleSignIn;
|
||||
@@ -1,594 +0,0 @@
|
||||
import type { ErrorResponse } from "@nhost/nhost-js/auth";
|
||||
import type { FetchError } from "@nhost/nhost-js/fetch";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
Dimensions,
|
||||
Image,
|
||||
Keyboard,
|
||||
KeyboardAvoidingView,
|
||||
Modal,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
TouchableWithoutFeedback,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useAuth } from "../lib/nhost/AuthProvider";
|
||||
|
||||
interface MFASettingsProps {
|
||||
initialMfaEnabled: boolean;
|
||||
}
|
||||
|
||||
export default function MFASettings({ initialMfaEnabled }: MFASettingsProps) {
|
||||
const [isMfaEnabled, setIsMfaEnabled] = useState<boolean>(initialMfaEnabled);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
const { nhost } = useAuth();
|
||||
|
||||
// Update internal state when prop changes
|
||||
useEffect(() => {
|
||||
if (initialMfaEnabled !== isMfaEnabled) {
|
||||
setIsMfaEnabled(initialMfaEnabled);
|
||||
}
|
||||
}, [initialMfaEnabled, isMfaEnabled]);
|
||||
|
||||
// MFA setup states
|
||||
const [isSettingUpMfa, setIsSettingUpMfa] = useState<boolean>(false);
|
||||
const [totpSecret, setTotpSecret] = useState<string>("");
|
||||
const [qrCodeUrl, setQrCodeUrl] = useState<string>("");
|
||||
const [verificationCode, setVerificationCode] = useState<string>("");
|
||||
const [qrCodeModalVisible, setQrCodeModalVisible] = useState<boolean>(false);
|
||||
|
||||
// Disabling MFA states
|
||||
const [isDisablingMfa, setIsDisablingMfa] = useState<boolean>(false);
|
||||
const [disableVerificationCode, setDisableVerificationCode] =
|
||||
useState<string>("");
|
||||
|
||||
// Begin MFA setup process
|
||||
const handleEnableMfa = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
|
||||
try {
|
||||
// Generate TOTP secret
|
||||
const response = await nhost.auth.changeUserMfa();
|
||||
setTotpSecret(response.body.totpSecret);
|
||||
setQrCodeUrl(response.body.imageUrl);
|
||||
setIsSettingUpMfa(true);
|
||||
} catch (err) {
|
||||
const errMessage = err instanceof Error ? err.message : "Unknown error";
|
||||
setError(`An error occurred while enabling MFA: ${errMessage}`);
|
||||
Alert.alert("Error", `Failed to enable MFA: ${errMessage}`);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Verify TOTP and enable MFA
|
||||
const handleVerifyTotp = async () => {
|
||||
if (!verificationCode) {
|
||||
setError("Please enter the verification code");
|
||||
Alert.alert("Error", "Please enter the verification code");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
|
||||
try {
|
||||
// Verify and activate MFA
|
||||
await nhost.auth.verifyChangeUserMfa({
|
||||
activeMfaType: "totp",
|
||||
code: verificationCode,
|
||||
});
|
||||
|
||||
setIsMfaEnabled(true);
|
||||
setIsSettingUpMfa(false);
|
||||
setSuccess("MFA has been successfully enabled.");
|
||||
Alert.alert("Success", "MFA has been successfully enabled.");
|
||||
} catch (err) {
|
||||
const errMessage = err instanceof Error ? err.message : "Unknown error";
|
||||
setError(`An error occurred while verifying the code: ${errMessage}`);
|
||||
Alert.alert("Error", `Failed to verify code: ${errMessage}`);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Show disable MFA confirmation
|
||||
const handleShowDisableMfa = () => {
|
||||
setIsDisablingMfa(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
};
|
||||
|
||||
// Disable MFA
|
||||
const handleDisableMfa = async () => {
|
||||
if (!disableVerificationCode) {
|
||||
setError("Please enter your verification code to confirm");
|
||||
Alert.alert("Error", "Please enter your verification code to confirm");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
|
||||
try {
|
||||
// Disable MFA by setting activeMfaType to empty string
|
||||
await nhost.auth.verifyChangeUserMfa({
|
||||
activeMfaType: "",
|
||||
code: disableVerificationCode,
|
||||
});
|
||||
|
||||
setIsMfaEnabled(false);
|
||||
setIsDisablingMfa(false);
|
||||
setDisableVerificationCode("");
|
||||
setSuccess("MFA has been successfully disabled.");
|
||||
Alert.alert("Success", "MFA has been successfully disabled.");
|
||||
} catch (err) {
|
||||
const error = err as FetchError<ErrorResponse>;
|
||||
setError(`An error occurred while disabling MFA: ${error.message}`);
|
||||
Alert.alert("Error", `Failed to disable MFA: ${error.message}`);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Cancel MFA setup
|
||||
const handleCancelMfaSetup = () => {
|
||||
setIsSettingUpMfa(false);
|
||||
setTotpSecret("");
|
||||
setQrCodeUrl("");
|
||||
setVerificationCode("");
|
||||
};
|
||||
|
||||
// Cancel MFA disable
|
||||
const handleCancelMfaDisable = () => {
|
||||
setIsDisablingMfa(false);
|
||||
setDisableVerificationCode("");
|
||||
setError(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>Multi-Factor Authentication</Text>
|
||||
|
||||
{error && (
|
||||
<View style={styles.errorContainer}>
|
||||
<Text style={styles.errorText}>{error}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<View style={styles.successContainer}>
|
||||
<Text style={styles.successText}>{success}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{isSettingUpMfa ? (
|
||||
<KeyboardAvoidingView behavior="padding" style={{ flex: 1 }}>
|
||||
<TouchableWithoutFeedback onPress={Keyboard.dismiss}>
|
||||
<ScrollView
|
||||
style={{ flex: 1 }}
|
||||
contentContainerStyle={styles.contentContainer}
|
||||
>
|
||||
<Text style={styles.instructionText}>
|
||||
Scan this QR code with your authenticator app (e.g., Google
|
||||
Authenticator, Authy):
|
||||
</Text>
|
||||
|
||||
{qrCodeUrl && (
|
||||
<TouchableOpacity
|
||||
style={styles.qrCodeContainer}
|
||||
onPress={() => setQrCodeModalVisible(true)}
|
||||
>
|
||||
<Image
|
||||
source={{ uri: qrCodeUrl }}
|
||||
style={styles.qrCode}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
<Text style={styles.copyHint}>(Tap to enlarge)</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
animationType="slide"
|
||||
transparent={true}
|
||||
visible={qrCodeModalVisible}
|
||||
onRequestClose={() => setQrCodeModalVisible(false)}
|
||||
>
|
||||
<View style={styles.modalOverlay}>
|
||||
<View style={styles.modalContent}>
|
||||
<Text style={styles.modalTitle}>Scan QR Code</Text>
|
||||
<Image
|
||||
source={{ uri: qrCodeUrl }}
|
||||
style={styles.largeQrCode}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
<TouchableOpacity
|
||||
style={styles.closeButton}
|
||||
onPress={() => setQrCodeModalVisible(false)}
|
||||
>
|
||||
<Text style={styles.closeButtonText}>Close</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
|
||||
<Text style={styles.instructionText}>
|
||||
Or manually enter this secret key:
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.secretContainer}
|
||||
onPress={async () => {
|
||||
await Clipboard.setStringAsync(totpSecret);
|
||||
Alert.alert("Copied", "Secret key copied to clipboard");
|
||||
}}
|
||||
>
|
||||
<Text style={styles.secretText}>{totpSecret}</Text>
|
||||
<Text style={styles.copyHint}>(Tap to copy)</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.inputContainer}>
|
||||
<Text style={styles.label}>Verification Code</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={verificationCode}
|
||||
onChangeText={setVerificationCode}
|
||||
placeholder="Enter 6-digit code"
|
||||
maxLength={6}
|
||||
keyboardType="number-pad"
|
||||
returnKeyType="done"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={[styles.buttonRow, { marginBottom: 30 }]}>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.button,
|
||||
styles.primaryButton,
|
||||
(!verificationCode || isLoading) && styles.disabledButton,
|
||||
]}
|
||||
onPress={handleVerifyTotp}
|
||||
disabled={isLoading || !verificationCode}
|
||||
>
|
||||
{isLoading ? (
|
||||
<ActivityIndicator size="small" color="#fff" />
|
||||
) : (
|
||||
<Text style={styles.buttonText}>Verify and Enable</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.button, styles.secondaryButton]}
|
||||
onPress={handleCancelMfaSetup}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Text style={styles.secondaryButtonText}>Cancel</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</TouchableWithoutFeedback>
|
||||
</KeyboardAvoidingView>
|
||||
) : isDisablingMfa ? (
|
||||
<KeyboardAvoidingView behavior="padding" style={{ flex: 1 }}>
|
||||
<TouchableWithoutFeedback onPress={Keyboard.dismiss}>
|
||||
<ScrollView
|
||||
style={{ flex: 1 }}
|
||||
contentContainerStyle={styles.contentContainer}
|
||||
>
|
||||
<Text style={styles.instructionText}>
|
||||
To disable Multi-Factor Authentication, please enter the current
|
||||
verification code from your authenticator app.
|
||||
</Text>
|
||||
|
||||
<View style={styles.inputContainer}>
|
||||
<Text style={styles.label}>Current Verification Code</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={disableVerificationCode}
|
||||
onChangeText={setDisableVerificationCode}
|
||||
placeholder="Enter 6-digit code"
|
||||
maxLength={6}
|
||||
keyboardType="number-pad"
|
||||
returnKeyType="done"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={[styles.buttonRow, { marginBottom: 30 }]}>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.button,
|
||||
styles.primaryButton,
|
||||
(!disableVerificationCode || isLoading) &&
|
||||
styles.disabledButton,
|
||||
]}
|
||||
onPress={handleDisableMfa}
|
||||
disabled={isLoading || !disableVerificationCode}
|
||||
>
|
||||
{isLoading ? (
|
||||
<ActivityIndicator size="small" color="#fff" />
|
||||
) : (
|
||||
<Text style={styles.buttonText}>Confirm Disable</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.button, styles.secondaryButton]}
|
||||
onPress={handleCancelMfaDisable}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Text style={styles.secondaryButtonText}>Cancel</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</TouchableWithoutFeedback>
|
||||
</KeyboardAvoidingView>
|
||||
) : (
|
||||
<View style={styles.contentContainer}>
|
||||
<Text style={styles.instructionText}>
|
||||
Multi-Factor Authentication adds an extra layer of security to your
|
||||
account by requiring a verification code from your authenticator app
|
||||
when signing in.
|
||||
</Text>
|
||||
|
||||
<View style={styles.statusContainer}>
|
||||
<Text style={styles.statusLabel}>Status:</Text>
|
||||
<Text
|
||||
style={[
|
||||
styles.statusValue,
|
||||
isMfaEnabled ? styles.enabledText : styles.disabledText,
|
||||
]}
|
||||
>
|
||||
{isMfaEnabled ? "Enabled" : "Disabled"}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{isMfaEnabled ? (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.button,
|
||||
styles.secondaryButton,
|
||||
isLoading && styles.disabledButton,
|
||||
]}
|
||||
onPress={handleShowDisableMfa}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<ActivityIndicator size="small" color="#6366f1" />
|
||||
) : (
|
||||
<Text style={styles.secondaryButtonText}>Disable MFA</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.button,
|
||||
styles.primaryButton,
|
||||
isLoading && styles.disabledButton,
|
||||
]}
|
||||
onPress={handleEnableMfa}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<ActivityIndicator size="small" color="#fff" />
|
||||
) : (
|
||||
<Text style={styles.buttonText}>Enable MFA</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
backgroundColor: "#fff",
|
||||
borderRadius: 10,
|
||||
padding: 16,
|
||||
marginBottom: 20,
|
||||
shadowColor: "#000",
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 2,
|
||||
},
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 3.84,
|
||||
elevation: 5,
|
||||
flex: 1,
|
||||
},
|
||||
title: {
|
||||
fontSize: 18,
|
||||
fontWeight: "bold",
|
||||
marginBottom: 16,
|
||||
color: "#333",
|
||||
},
|
||||
contentContainer: {
|
||||
marginTop: 10,
|
||||
},
|
||||
errorContainer: {
|
||||
backgroundColor: "#fee2e2",
|
||||
padding: 12,
|
||||
borderRadius: 6,
|
||||
marginBottom: 16,
|
||||
borderLeftWidth: 4,
|
||||
borderLeftColor: "#ef4444",
|
||||
},
|
||||
errorText: {
|
||||
color: "#b91c1c",
|
||||
fontSize: 14,
|
||||
},
|
||||
successContainer: {
|
||||
backgroundColor: "#dcfce7",
|
||||
padding: 12,
|
||||
borderRadius: 6,
|
||||
marginBottom: 16,
|
||||
borderLeftWidth: 4,
|
||||
borderLeftColor: "#10b981",
|
||||
},
|
||||
successText: {
|
||||
color: "#047857",
|
||||
fontSize: 14,
|
||||
},
|
||||
instructionText: {
|
||||
fontSize: 14,
|
||||
color: "#4b5563",
|
||||
marginBottom: 16,
|
||||
},
|
||||
qrCodeContainer: {
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: "#fff",
|
||||
padding: 10,
|
||||
marginVertical: 16,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: "#e5e7eb",
|
||||
},
|
||||
qrCode: {
|
||||
width: 200,
|
||||
height: 200,
|
||||
},
|
||||
modalOverlay: {
|
||||
flex: 1,
|
||||
backgroundColor: "rgba(0,0,0,0.5)",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
modalContent: {
|
||||
backgroundColor: "white",
|
||||
borderRadius: 12,
|
||||
padding: 20,
|
||||
alignItems: "center",
|
||||
elevation: 5,
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 3.84,
|
||||
width: Dimensions.get("window").width * 0.9,
|
||||
},
|
||||
largeQrCode: {
|
||||
width: Dimensions.get("window").width * 0.7,
|
||||
height: Dimensions.get("window").width * 0.7,
|
||||
marginVertical: 20,
|
||||
},
|
||||
modalTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: "bold",
|
||||
marginBottom: 15,
|
||||
},
|
||||
closeButton: {
|
||||
backgroundColor: "#6366f1",
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 30,
|
||||
borderRadius: 6,
|
||||
},
|
||||
closeButtonText: {
|
||||
color: "white",
|
||||
fontWeight: "600",
|
||||
},
|
||||
secretContainer: {
|
||||
backgroundColor: "#f3f4f6",
|
||||
padding: 12,
|
||||
borderRadius: 6,
|
||||
marginBottom: 16,
|
||||
alignItems: "center",
|
||||
borderWidth: 1,
|
||||
borderColor: "#d1d5db",
|
||||
borderStyle: "dashed",
|
||||
},
|
||||
secretText: {
|
||||
fontFamily: "monospace",
|
||||
fontSize: 14,
|
||||
color: "#111827",
|
||||
marginBottom: 4,
|
||||
},
|
||||
copyHint: {
|
||||
fontSize: 12,
|
||||
color: "#6366f1",
|
||||
fontStyle: "italic",
|
||||
},
|
||||
inputContainer: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
label: {
|
||||
fontSize: 14,
|
||||
fontWeight: "500",
|
||||
marginBottom: 8,
|
||||
color: "#374151",
|
||||
},
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderColor: "#d1d5db",
|
||||
borderRadius: 6,
|
||||
padding: 10,
|
||||
fontSize: 16,
|
||||
backgroundColor: "#f9fafb",
|
||||
},
|
||||
buttonRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
marginTop: 8,
|
||||
},
|
||||
button: {
|
||||
flex: 1,
|
||||
padding: 12,
|
||||
borderRadius: 6,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
primaryButton: {
|
||||
backgroundColor: "#6366f1",
|
||||
marginRight: 8,
|
||||
},
|
||||
secondaryButton: {
|
||||
backgroundColor: "#f3f4f6",
|
||||
marginLeft: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: "#d1d5db",
|
||||
},
|
||||
disabledButton: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
buttonText: {
|
||||
color: "#fff",
|
||||
fontWeight: "600",
|
||||
fontSize: 14,
|
||||
},
|
||||
secondaryButtonText: {
|
||||
color: "#4b5563",
|
||||
fontWeight: "600",
|
||||
fontSize: 14,
|
||||
},
|
||||
statusContainer: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
marginBottom: 20,
|
||||
},
|
||||
statusLabel: {
|
||||
fontSize: 14,
|
||||
color: "#4b5563",
|
||||
marginRight: 8,
|
||||
},
|
||||
statusValue: {
|
||||
fontSize: 14,
|
||||
fontWeight: "600",
|
||||
},
|
||||
enabledText: {
|
||||
color: "#10b981",
|
||||
},
|
||||
disabledText: {
|
||||
color: "#f59e0b",
|
||||
},
|
||||
});
|
||||
@@ -1,158 +0,0 @@
|
||||
import type { ErrorResponse } from "@nhost/nhost-js/auth";
|
||||
import type { FetchError } from "@nhost/nhost-js/fetch";
|
||||
import * as Linking from "expo-linking";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useAuth } from "../lib/nhost/AuthProvider";
|
||||
|
||||
interface MagicLinkFormProps {
|
||||
buttonLabel?: string;
|
||||
}
|
||||
|
||||
export default function MagicLinkForm({
|
||||
buttonLabel = "Send Magic Link",
|
||||
}: MagicLinkFormProps) {
|
||||
const [email, setEmail] = useState<string>("");
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [success, setSuccess] = useState<boolean>(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { nhost } = useAuth();
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// For Expo Go, we need to create the correct URL format
|
||||
// This will work both in Expo Go and standalone app
|
||||
const redirectUrl = Linking.createURL("verify");
|
||||
|
||||
await nhost.auth.signInPasswordlessEmail({
|
||||
email,
|
||||
options: {
|
||||
redirectTo: redirectUrl,
|
||||
},
|
||||
});
|
||||
|
||||
setSuccess(true);
|
||||
} catch (err) {
|
||||
const error = err as FetchError<ErrorResponse>;
|
||||
setError(
|
||||
`An error occurred while sending the magic link: ${error.message}`,
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.successText}>
|
||||
Magic link sent! Check your email to sign in.
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.secondaryButton}
|
||||
onPress={() => setSuccess(false)}
|
||||
>
|
||||
<Text style={styles.secondaryButtonText}>Try again</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.label}>Email</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
placeholder="Enter your email"
|
||||
keyboardType="email-address"
|
||||
autoCapitalize="none"
|
||||
autoComplete="email"
|
||||
/>
|
||||
</View>
|
||||
|
||||
{error && <Text style={styles.errorText}>{error}</Text>}
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.button}
|
||||
onPress={handleSubmit}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<ActivityIndicator size="small" color="#fff" />
|
||||
) : (
|
||||
<Text style={styles.buttonText}>{buttonLabel}</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
width: "100%",
|
||||
},
|
||||
inputGroup: {
|
||||
marginBottom: 15,
|
||||
},
|
||||
label: {
|
||||
fontSize: 16,
|
||||
marginBottom: 5,
|
||||
color: "#333",
|
||||
},
|
||||
input: {
|
||||
height: 45,
|
||||
borderWidth: 1,
|
||||
borderColor: "#ddd",
|
||||
borderRadius: 5,
|
||||
paddingHorizontal: 10,
|
||||
fontSize: 16,
|
||||
backgroundColor: "#fafafa",
|
||||
},
|
||||
errorText: {
|
||||
color: "#e53e3e",
|
||||
marginBottom: 10,
|
||||
},
|
||||
successText: {
|
||||
fontSize: 16,
|
||||
color: "#38a169",
|
||||
textAlign: "center",
|
||||
marginBottom: 15,
|
||||
},
|
||||
button: {
|
||||
backgroundColor: "#6366f1",
|
||||
paddingVertical: 12,
|
||||
borderRadius: 5,
|
||||
alignItems: "center",
|
||||
marginTop: 10,
|
||||
},
|
||||
buttonText: {
|
||||
color: "#fff",
|
||||
fontSize: 16,
|
||||
fontWeight: "600",
|
||||
},
|
||||
secondaryButton: {
|
||||
backgroundColor: "#e2e8f0",
|
||||
paddingVertical: 12,
|
||||
borderRadius: 5,
|
||||
alignItems: "center",
|
||||
marginTop: 10,
|
||||
},
|
||||
secondaryButtonText: {
|
||||
color: "#4a5568",
|
||||
fontSize: 16,
|
||||
fontWeight: "600",
|
||||
},
|
||||
});
|
||||
@@ -1,93 +0,0 @@
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Platform,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from "react-native";
|
||||
import AppleSignIn from "./AppleSignIn";
|
||||
|
||||
interface NativeLoginFormProps {
|
||||
action: "Sign In" | "Sign Up";
|
||||
isLoading: boolean;
|
||||
setAppleAuthInProgress: (inProgress: boolean) => void;
|
||||
}
|
||||
|
||||
export default function NativeLoginForm({
|
||||
action,
|
||||
isLoading,
|
||||
setAppleAuthInProgress,
|
||||
}: NativeLoginFormProps) {
|
||||
// Function to update loading state
|
||||
const updateLoadingState = (loading: boolean) => {
|
||||
if (setAppleAuthInProgress) {
|
||||
setAppleAuthInProgress(loading);
|
||||
}
|
||||
};
|
||||
|
||||
// Check if we have any native options for this platform
|
||||
const hasAppleOption = Platform.OS === "ios";
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.text}>
|
||||
{action} using native authentication methods
|
||||
</Text>
|
||||
{isLoading ? (
|
||||
<ActivityIndicator size="large" color="#6366f1" />
|
||||
) : (
|
||||
<View style={styles.buttonContainer}>
|
||||
<AppleSignIn
|
||||
action={action}
|
||||
isLoading={isLoading}
|
||||
setIsLoading={updateLoadingState}
|
||||
/>
|
||||
|
||||
{!hasAppleOption && (
|
||||
<Text style={styles.noOptionsText}>
|
||||
No native authentication options available for your platform
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{hasAppleOption && (
|
||||
<Text style={styles.infoText}>
|
||||
Native sign-in methods provide a more streamlined authentication
|
||||
experience
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
alignItems: "center",
|
||||
paddingVertical: 10,
|
||||
width: "100%",
|
||||
},
|
||||
text: {
|
||||
fontSize: 16,
|
||||
marginBottom: 20,
|
||||
textAlign: "center",
|
||||
color: "#4a5568",
|
||||
},
|
||||
buttonContainer: {
|
||||
width: "100%",
|
||||
alignItems: "center",
|
||||
},
|
||||
infoText: {
|
||||
marginTop: 10,
|
||||
fontSize: 12,
|
||||
color: "#718096",
|
||||
textAlign: "center",
|
||||
},
|
||||
noOptionsText: {
|
||||
marginTop: 20,
|
||||
fontSize: 14,
|
||||
color: "#a0aec0",
|
||||
textAlign: "center",
|
||||
fontStyle: "italic",
|
||||
},
|
||||
});
|
||||
@@ -1,40 +0,0 @@
|
||||
import { router } from "expo-router";
|
||||
import type React from "react";
|
||||
import { useEffect } from "react";
|
||||
import { ActivityIndicator, Text, View } from "react-native";
|
||||
import { useAuth } from "../lib/nhost/AuthProvider";
|
||||
|
||||
type AppRoutes = "/" | "/signin" | "/signup" | "/profile";
|
||||
|
||||
interface ProtectedScreenProps {
|
||||
children: React.ReactNode;
|
||||
redirectTo?: AppRoutes;
|
||||
}
|
||||
|
||||
export default function ProtectedScreen({
|
||||
children,
|
||||
redirectTo = "/signin",
|
||||
}: ProtectedScreenProps) {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isAuthenticated) {
|
||||
router.replace(redirectTo);
|
||||
}
|
||||
}, [isAuthenticated, isLoading, redirectTo]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
|
||||
<ActivityIndicator size="large" color="#0000ff" />
|
||||
<Text style={{ marginTop: 10 }}>Loading...</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return null; // Will redirect in useEffect
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import * as Linking from "expo-linking";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useAuth } from "../lib/nhost/AuthProvider";
|
||||
|
||||
interface SocialLoginFormProps {
|
||||
action: "Sign In" | "Sign Up";
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export default function SocialLoginForm({
|
||||
action,
|
||||
isLoading: initialLoading = false,
|
||||
}: SocialLoginFormProps) {
|
||||
const { nhost } = useAuth();
|
||||
const [isLoading] = useState(initialLoading);
|
||||
|
||||
const handleSocialLogin = (provider: "github") => {
|
||||
// Use the same redirect URL approach as the magic link
|
||||
const redirectUrl = Linking.createURL("verify");
|
||||
|
||||
// Sign in with the specified provider
|
||||
const url = nhost.auth.signInProviderURL(provider, {
|
||||
redirectTo: redirectUrl,
|
||||
});
|
||||
|
||||
// Open the URL in browser
|
||||
void Linking.openURL(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.socialContainer}>
|
||||
<Text style={styles.socialText}>{action} using your Social account</Text>
|
||||
{isLoading ? (
|
||||
<ActivityIndicator size="large" color="#6366f1" />
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
style={styles.socialButton}
|
||||
onPress={() => handleSocialLogin("github")}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<View style={styles.buttonContent}>
|
||||
<Ionicons name="logo-github" size={22} style={styles.githubIcon} />
|
||||
<Text style={styles.socialButtonText}>Continue with GitHub</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
socialContainer: {
|
||||
alignItems: "center",
|
||||
paddingVertical: 10,
|
||||
},
|
||||
socialText: {
|
||||
fontSize: 16,
|
||||
marginBottom: 20,
|
||||
textAlign: "center",
|
||||
color: "#4a5568",
|
||||
},
|
||||
socialButton: {
|
||||
backgroundColor: "#24292e",
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 15,
|
||||
borderRadius: 5,
|
||||
width: "100%",
|
||||
},
|
||||
buttonContent: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
githubIcon: {
|
||||
marginRight: 10,
|
||||
color: "#ffffff",
|
||||
},
|
||||
socialButtonText: {
|
||||
color: "#ffffff",
|
||||
fontSize: 16,
|
||||
fontWeight: "600",
|
||||
},
|
||||
});
|
||||
@@ -1,120 +0,0 @@
|
||||
import { useRouter } from "expo-router";
|
||||
import { StyleSheet, Text, TouchableOpacity, View } from "react-native";
|
||||
import { useAuth } from "./lib/nhost/AuthProvider";
|
||||
|
||||
export default function Index() {
|
||||
const router = useRouter();
|
||||
const { isAuthenticated, user } = useAuth();
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>Nhost SDK Demo</Text>
|
||||
<Text style={styles.subtitle}>React Native Example</Text>
|
||||
|
||||
<View style={styles.contentContainer}>
|
||||
{isAuthenticated ? (
|
||||
<>
|
||||
<Text style={styles.welcomeText}>
|
||||
Welcome back, {user?.displayName || user?.email || "User"}!
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.button}
|
||||
onPress={() => router.push("/profile")}
|
||||
>
|
||||
<Text style={styles.buttonText}>Go to Profile</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.button, styles.secondaryButton]}
|
||||
onPress={() => router.push("/upload")}
|
||||
>
|
||||
<Text style={styles.buttonText}>File Upload</Text>
|
||||
</TouchableOpacity>
|
||||
</>
|
||||
) : (
|
||||
<View style={styles.authButtons}>
|
||||
<TouchableOpacity
|
||||
style={styles.button}
|
||||
onPress={() => router.push("/signin")}
|
||||
>
|
||||
<Text style={styles.buttonText}>Sign In</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.button, styles.secondaryButton]}
|
||||
onPress={() => router.push("/signup")}
|
||||
>
|
||||
<Text style={styles.buttonText}>Sign Up</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
padding: 20,
|
||||
backgroundColor: "#f5f5f5",
|
||||
},
|
||||
title: {
|
||||
fontSize: 28,
|
||||
fontWeight: "bold",
|
||||
marginBottom: 8,
|
||||
color: "#333",
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 18,
|
||||
marginBottom: 30,
|
||||
color: "#666",
|
||||
},
|
||||
contentContainer: {
|
||||
width: "100%",
|
||||
maxWidth: 400,
|
||||
backgroundColor: "#fff",
|
||||
borderRadius: 10,
|
||||
padding: 20,
|
||||
alignItems: "center",
|
||||
shadowColor: "#000",
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 2,
|
||||
},
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 3.84,
|
||||
elevation: 5,
|
||||
},
|
||||
welcomeText: {
|
||||
fontSize: 18,
|
||||
marginBottom: 20,
|
||||
textAlign: "center",
|
||||
},
|
||||
buttonContainer: {
|
||||
width: "100%",
|
||||
gap: 15,
|
||||
},
|
||||
authButtons: {
|
||||
width: "100%",
|
||||
gap: 15,
|
||||
},
|
||||
button: {
|
||||
backgroundColor: "#6366f1",
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 20,
|
||||
borderRadius: 8,
|
||||
width: "100%",
|
||||
alignItems: "center",
|
||||
marginTop: 10,
|
||||
},
|
||||
secondaryButton: {
|
||||
backgroundColor: "#818cf8",
|
||||
},
|
||||
buttonText: {
|
||||
color: "#fff",
|
||||
fontSize: 16,
|
||||
fontWeight: "600",
|
||||
},
|
||||
});
|
||||
@@ -1,93 +0,0 @@
|
||||
import {
|
||||
DEFAULT_SESSION_KEY,
|
||||
type Session,
|
||||
type SessionStorageBackend,
|
||||
} from "@nhost/nhost-js/session";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
|
||||
/**
|
||||
* Custom storage implementation for React Native using AsyncStorage
|
||||
* to persist the Nhost session on the device.
|
||||
*
|
||||
* This implementation synchronously works with the SessionStorageBackend interface
|
||||
* while ensuring reliable persistence with AsyncStorage for Expo Go.
|
||||
*/
|
||||
export default class NhostAsyncStorage implements SessionStorageBackend {
|
||||
private key: string;
|
||||
private cache: Session | null = null;
|
||||
|
||||
constructor(key: string = DEFAULT_SESSION_KEY) {
|
||||
this.key = key;
|
||||
|
||||
// Immediately try to load from AsyncStorage
|
||||
this.loadFromAsyncStorage();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the session from AsyncStorage synchronously if possible
|
||||
*/
|
||||
private loadFromAsyncStorage(): void {
|
||||
// Try to get cached data from AsyncStorage immediately
|
||||
try {
|
||||
AsyncStorage.getItem(this.key)
|
||||
.then((value) => {
|
||||
if (value) {
|
||||
try {
|
||||
this.cache = JSON.parse(value) as Session;
|
||||
} catch (error) {
|
||||
console.warn("Error parsing session from AsyncStorage:", error);
|
||||
this.cache = null;
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn("Error loading from AsyncStorage:", error);
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn("AsyncStorage access error:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the session from the in-memory cache
|
||||
*/
|
||||
get(): Session | null {
|
||||
return this.cache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the session in the in-memory cache and persists to AsyncStorage
|
||||
* Ensures the data gets written by using an immediately invoked async function
|
||||
*/
|
||||
set(value: Session): void {
|
||||
// Update cache immediately
|
||||
this.cache = value;
|
||||
|
||||
// Persist to AsyncStorage with better error handling
|
||||
void (async () => {
|
||||
try {
|
||||
await AsyncStorage.setItem(this.key, JSON.stringify(value));
|
||||
} catch (error) {
|
||||
console.warn("Error saving session to AsyncStorage:", error);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the session from the in-memory cache and AsyncStorage
|
||||
* Ensures the data gets removed by using an immediately invoked async function
|
||||
*/
|
||||
remove(): void {
|
||||
// Clear cache immediately
|
||||
this.cache = null;
|
||||
|
||||
// Remove from AsyncStorage with better error handling
|
||||
void (async () => {
|
||||
try {
|
||||
await AsyncStorage.removeItem(this.key);
|
||||
} catch (error) {
|
||||
console.warn("Error removing session from AsyncStorage:", error);
|
||||
}
|
||||
})();
|
||||
}
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
import { createClient, type NhostClient } from "@nhost/nhost-js";
|
||||
import type { Session } from "@nhost/nhost-js/session";
|
||||
import Constants from "expo-constants";
|
||||
import {
|
||||
createContext,
|
||||
type ReactNode,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import NhostAsyncStorage from "./AsyncStorage";
|
||||
|
||||
interface AuthContextType {
|
||||
user: Session["user"] | null;
|
||||
session: Session | null;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
nhost: NhostClient;
|
||||
}
|
||||
|
||||
// Create context for authentication state and nhost client
|
||||
const AuthContext = createContext<AuthContextType | null>(null);
|
||||
|
||||
interface AuthProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const AuthProvider = ({ children }: AuthProviderProps) => {
|
||||
const [user, setUser] = useState<Session["user"] | null>(null);
|
||||
const [session, setSession] = useState<Session | null>(null);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(false);
|
||||
|
||||
// Create the nhost client with persistent storage
|
||||
const nhost = useMemo(() => {
|
||||
// Get configuration values with type assertion
|
||||
const subdomain =
|
||||
(Constants.expoConfig?.extra?.["NHOST_SUBDOMAIN"] as string) ||
|
||||
"192-168-1-103";
|
||||
const region =
|
||||
(Constants.expoConfig?.extra?.["NHOST_REGION"] as string) || "local";
|
||||
|
||||
return createClient({
|
||||
subdomain,
|
||||
region,
|
||||
storage: new NhostAsyncStorage(),
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Initialize authentication state
|
||||
setIsLoading(true);
|
||||
|
||||
// Allow enough time for AsyncStorage to be read and session to be restored
|
||||
const initializeSession = async () => {
|
||||
try {
|
||||
// Let's wait a bit to ensure AsyncStorage has been read
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
// Now try to get the current session
|
||||
const currentSession = nhost.getUserSession();
|
||||
|
||||
setUser(currentSession?.user || null);
|
||||
setSession(currentSession);
|
||||
setIsAuthenticated(!!currentSession);
|
||||
} catch (error) {
|
||||
console.warn("Error initializing session:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
void initializeSession();
|
||||
|
||||
// Listen for session changes
|
||||
const unsubscribe = nhost.sessionStorage.onChange((currentSession) => {
|
||||
setUser(currentSession?.user || null);
|
||||
setSession(currentSession);
|
||||
setIsAuthenticated(!!currentSession);
|
||||
});
|
||||
|
||||
// Clean up subscription on unmount
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [nhost]);
|
||||
|
||||
// Context value with nhost client directly exposed
|
||||
const value: AuthContextType = {
|
||||
user,
|
||||
session,
|
||||
isAuthenticated,
|
||||
isLoading,
|
||||
nhost,
|
||||
};
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
};
|
||||
|
||||
// Custom hook to use the auth context
|
||||
export const useAuth = (): AuthContextType => {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error("useAuth must be used within an AuthProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export default AuthProvider;
|
||||
@@ -1,44 +0,0 @@
|
||||
/**
|
||||
* Formats a file size in bytes to a human-readable string
|
||||
* @param bytes File size in bytes
|
||||
* @param decimals Number of decimal places to show
|
||||
* @returns Formatted file size string (e.g., "1.23 MB")
|
||||
*/
|
||||
export function formatFileSize(bytes: number, decimals = 2): string {
|
||||
if (bytes === 0) return "0 Bytes";
|
||||
|
||||
const k = 1024;
|
||||
const dm = decimals < 0 ? 0 : decimals;
|
||||
const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
|
||||
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return `${parseFloat((bytes / k ** i).toFixed(dm))} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default export to satisfy the Router's requirements
|
||||
* This utilities file primarily exports helper functions
|
||||
*/
|
||||
export default {
|
||||
formatFileSize,
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts a Blob to a Base64 string
|
||||
* @param blob The Blob object to convert
|
||||
* @returns A Promise that resolves to the Base64 string representation of the Blob
|
||||
*/
|
||||
export function blobToBase64(blob: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
const base64data = reader.result as string;
|
||||
// Remove the data URL prefix (e.g., "data:application/octet-stream;base64,")
|
||||
const base64Content = base64data.split(",")[1] || "";
|
||||
resolve(base64Content);
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
@@ -1,262 +0,0 @@
|
||||
import { router } from "expo-router";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import MFASettings from "./components/MFASettings";
|
||||
import ProtectedScreen from "./components/ProtectedScreen";
|
||||
import { useAuth } from "./lib/nhost/AuthProvider";
|
||||
|
||||
interface MfaStatusResponse {
|
||||
user?: {
|
||||
activeMfaType: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export default function Profile() {
|
||||
const { nhost, user, session, isAuthenticated } = useAuth();
|
||||
const [isMfaEnabled, setIsMfaEnabled] = useState<boolean>(false);
|
||||
|
||||
// Fetch MFA status when user is authenticated
|
||||
useEffect(() => {
|
||||
const fetchMfaStatus = async () => {
|
||||
if (!user?.id) return;
|
||||
|
||||
try {
|
||||
// Correctly structure GraphQL query with parameters
|
||||
const response = await nhost.graphql.request<MfaStatusResponse>({
|
||||
query: `
|
||||
query GetUserMfaStatus($userId: uuid!) {
|
||||
user(id: $userId) {
|
||||
activeMfaType
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
const activeMfaType = response.body?.data?.user?.activeMfaType;
|
||||
const newMfaEnabled = activeMfaType === "totp";
|
||||
|
||||
// Update the state
|
||||
setIsMfaEnabled(newMfaEnabled);
|
||||
} catch (err) {
|
||||
const errMessage =
|
||||
err instanceof Error ? err.message : "An unexpected error occurred";
|
||||
console.error(`Failed to query MFA status: ${errMessage}`);
|
||||
}
|
||||
};
|
||||
|
||||
if (isAuthenticated && user?.id) {
|
||||
void fetchMfaStatus();
|
||||
}
|
||||
}, [user, isAuthenticated, nhost.graphql]);
|
||||
|
||||
const handleSignOut = async () => {
|
||||
try {
|
||||
const session = nhost.getUserSession();
|
||||
if (session) {
|
||||
await nhost.auth.signOut({
|
||||
refreshToken: session.refreshToken,
|
||||
});
|
||||
}
|
||||
|
||||
router.replace("/signin");
|
||||
} catch {
|
||||
Alert.alert("Error", "Failed to sign out");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ProtectedScreen>
|
||||
<ScrollView
|
||||
style={styles.container}
|
||||
contentContainerStyle={styles.contentContainer}
|
||||
>
|
||||
<Text style={styles.title}>Your Profile</Text>
|
||||
|
||||
<View style={styles.card}>
|
||||
<View style={styles.profileItem}>
|
||||
<Text style={styles.itemLabel}>Display Name:</Text>
|
||||
<Text style={styles.itemValue}>
|
||||
{user?.displayName || "Not set"}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.profileItem}>
|
||||
<Text style={styles.itemLabel}>Email:</Text>
|
||||
<Text style={styles.itemValue}>
|
||||
{user?.email || "Not available"}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.profileItem}>
|
||||
<Text style={styles.itemLabel}>User ID:</Text>
|
||||
<Text
|
||||
style={styles.itemValue}
|
||||
numberOfLines={1}
|
||||
ellipsizeMode="middle"
|
||||
>
|
||||
{user?.id || "Not available"}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.profileItem}>
|
||||
<Text style={styles.itemLabel}>Roles:</Text>
|
||||
<Text style={styles.itemValue}>
|
||||
{user?.roles?.join(", ") || "None"}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.profileItem}>
|
||||
<Text style={styles.itemLabel}>Email Verified:</Text>
|
||||
<Text style={styles.itemValue}>
|
||||
{user?.emailVerified ? "Yes" : "No"}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.sectionTitle}>Session Information</Text>
|
||||
<View style={styles.sessionInfo}>
|
||||
<Text style={styles.sessionText}>Refresh Token ID:</Text>
|
||||
<Text
|
||||
style={styles.sessionValue}
|
||||
numberOfLines={1}
|
||||
ellipsizeMode="middle"
|
||||
>
|
||||
{session?.refreshTokenId || "None"}
|
||||
</Text>
|
||||
|
||||
<Text style={styles.sessionText}>Access Token Expires In:</Text>
|
||||
<Text style={styles.sessionValue}>
|
||||
{session?.accessTokenExpiresIn
|
||||
? `${session.accessTokenExpiresIn}s`
|
||||
: "N/A"}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<MFASettings
|
||||
key={`mfa-settings-${isMfaEnabled}`}
|
||||
initialMfaEnabled={isMfaEnabled}
|
||||
/>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.actionButton}
|
||||
onPress={() => router.push("/upload")}
|
||||
>
|
||||
<Text style={styles.actionButtonText}>File Upload</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity style={styles.signOutButton} onPress={handleSignOut}>
|
||||
<Text style={styles.signOutButtonText}>Sign Out</Text>
|
||||
</TouchableOpacity>
|
||||
</ScrollView>
|
||||
</ProtectedScreen>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: "#f5f5f5",
|
||||
},
|
||||
contentContainer: {
|
||||
padding: 20,
|
||||
paddingBottom: 40,
|
||||
},
|
||||
title: {
|
||||
fontSize: 24,
|
||||
fontWeight: "bold",
|
||||
marginBottom: 20,
|
||||
color: "#333",
|
||||
textAlign: "center",
|
||||
},
|
||||
card: {
|
||||
backgroundColor: "#fff",
|
||||
borderRadius: 10,
|
||||
padding: 16,
|
||||
marginBottom: 20,
|
||||
shadowColor: "#000",
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 2,
|
||||
},
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 3.84,
|
||||
elevation: 5,
|
||||
},
|
||||
profileItem: {
|
||||
paddingVertical: 12,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: "#f0f0f0",
|
||||
},
|
||||
itemLabel: {
|
||||
fontSize: 16,
|
||||
fontWeight: "bold",
|
||||
color: "#333",
|
||||
marginBottom: 4,
|
||||
},
|
||||
itemValue: {
|
||||
fontSize: 16,
|
||||
color: "#666",
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: "bold",
|
||||
marginBottom: 12,
|
||||
color: "#333",
|
||||
},
|
||||
sessionInfo: {
|
||||
backgroundColor: "#f8f8f8",
|
||||
padding: 12,
|
||||
borderRadius: 6,
|
||||
},
|
||||
sessionText: {
|
||||
fontSize: 14,
|
||||
fontWeight: "500",
|
||||
color: "#333",
|
||||
marginBottom: 2,
|
||||
},
|
||||
sessionValue: {
|
||||
fontSize: 14,
|
||||
color: "#666",
|
||||
marginBottom: 10,
|
||||
fontFamily: "monospace",
|
||||
},
|
||||
actionButton: {
|
||||
backgroundColor: "#6366f1",
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 20,
|
||||
borderRadius: 8,
|
||||
alignItems: "center",
|
||||
marginTop: 10,
|
||||
marginBottom: 10,
|
||||
},
|
||||
actionButtonText: {
|
||||
color: "#fff",
|
||||
fontSize: 16,
|
||||
fontWeight: "600",
|
||||
},
|
||||
signOutButton: {
|
||||
backgroundColor: "#e53e3e",
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 20,
|
||||
borderRadius: 8,
|
||||
alignItems: "center",
|
||||
marginTop: 10,
|
||||
},
|
||||
signOutButtonText: {
|
||||
color: "#fff",
|
||||
fontSize: 16,
|
||||
fontWeight: "600",
|
||||
},
|
||||
});
|
||||
@@ -1,367 +0,0 @@
|
||||
import { Link, router, useLocalSearchParams } from "expo-router";
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
KeyboardAvoidingView,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import MagicLinkForm from "./components/MagicLinkForm";
|
||||
import NativeLoginForm from "./components/NativeLoginForm";
|
||||
import SocialLoginForm from "./components/SocialLoginForm";
|
||||
import { useAuth } from "./lib/nhost/AuthProvider";
|
||||
|
||||
export default function SignIn() {
|
||||
const { nhost, isAuthenticated } = useAuth();
|
||||
const params = useLocalSearchParams();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [appleAuthInProgress, setAppleAuthInProgress] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<
|
||||
"password" | "magic" | "social" | "native"
|
||||
>("password");
|
||||
|
||||
const magicLinkSent = params["magic"] === "success";
|
||||
|
||||
// If already authenticated, redirect to profile
|
||||
React.useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
router.replace("/profile");
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Use the signIn function from auth context
|
||||
const response = await nhost.auth.signInEmailPassword({
|
||||
email,
|
||||
password,
|
||||
});
|
||||
|
||||
// Check if MFA is required
|
||||
if (response.body?.mfa) {
|
||||
router.push(`/signin/mfa?ticket=${response.body.mfa.ticket}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// If we have a session, sign in was successful
|
||||
if (response.body?.session) {
|
||||
router.replace("/profile");
|
||||
} else {
|
||||
setError("Failed to sign in");
|
||||
}
|
||||
} catch (err) {
|
||||
const errMessage =
|
||||
err instanceof Error ? err.message : "An unexpected error occurred";
|
||||
setError(`An error occurred during sign in: ${errMessage}`);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView behavior="padding" style={styles.container}>
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.scrollContainer}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
<Text style={styles.title}>Nhost SDK Demo</Text>
|
||||
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.cardTitle}>Sign In</Text>
|
||||
|
||||
{magicLinkSent ? (
|
||||
<View style={styles.messageContainer}>
|
||||
<Text style={styles.successText}>
|
||||
Magic link sent! Check your email to sign in.
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.secondaryButton}
|
||||
onPress={() => router.setParams({ magic: "" })}
|
||||
>
|
||||
<Text style={styles.secondaryButtonText}>Back to sign in</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
<View style={styles.tabContainer}>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.tabButton,
|
||||
activeTab === "password" && styles.activeTab,
|
||||
]}
|
||||
onPress={() => setActiveTab("password")}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.tabText,
|
||||
activeTab === "password" && styles.activeTabText,
|
||||
]}
|
||||
>
|
||||
Password
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.tabButton,
|
||||
activeTab === "magic" && styles.activeTab,
|
||||
]}
|
||||
onPress={() => setActiveTab("magic")}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.tabText,
|
||||
activeTab === "magic" && styles.activeTabText,
|
||||
]}
|
||||
>
|
||||
Magic Link
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.tabButton,
|
||||
activeTab === "social" && styles.activeTab,
|
||||
]}
|
||||
onPress={() => setActiveTab("social")}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.tabText,
|
||||
activeTab === "social" && styles.activeTabText,
|
||||
]}
|
||||
>
|
||||
Social
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.tabButton,
|
||||
activeTab === "native" && styles.activeTab,
|
||||
]}
|
||||
onPress={() => setActiveTab("native")}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.tabText,
|
||||
activeTab === "native" && styles.activeTabText,
|
||||
]}
|
||||
>
|
||||
Native
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={styles.form}>
|
||||
{activeTab === "password" ? (
|
||||
<>
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.label}>Email</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
placeholder="Enter your email"
|
||||
keyboardType="email-address"
|
||||
autoCapitalize="none"
|
||||
autoComplete="email"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.label}>Password</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
placeholder="Enter your password"
|
||||
secureTextEntry
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
</View>
|
||||
|
||||
{error && <Text style={styles.errorText}>{error}</Text>}
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.button}
|
||||
onPress={handleSubmit}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<ActivityIndicator size="small" color="#fff" />
|
||||
) : (
|
||||
<Text style={styles.buttonText}>Sign In</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</>
|
||||
) : activeTab === "magic" ? (
|
||||
<MagicLinkForm buttonLabel="Sign In with Magic Link" />
|
||||
) : activeTab === "social" ? (
|
||||
<SocialLoginForm action="Sign In" isLoading={isLoading} />
|
||||
) : (
|
||||
<NativeLoginForm
|
||||
action="Sign In"
|
||||
isLoading={isLoading || appleAuthInProgress}
|
||||
setAppleAuthInProgress={setAppleAuthInProgress}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.footer}>
|
||||
<Text style={styles.footerText}>
|
||||
Don't have an account?{" "}
|
||||
<Link href="/signup" style={styles.link}>
|
||||
Sign Up
|
||||
</Link>
|
||||
</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: "#f5f5f5",
|
||||
},
|
||||
scrollContainer: {
|
||||
flexGrow: 1,
|
||||
justifyContent: "center",
|
||||
padding: 20,
|
||||
},
|
||||
title: {
|
||||
fontSize: 24,
|
||||
fontWeight: "bold",
|
||||
textAlign: "center",
|
||||
marginBottom: 20,
|
||||
color: "#333",
|
||||
},
|
||||
card: {
|
||||
width: "100%",
|
||||
maxWidth: 400,
|
||||
backgroundColor: "#fff",
|
||||
borderRadius: 10,
|
||||
padding: 20,
|
||||
alignSelf: "center",
|
||||
shadowColor: "#000",
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 2,
|
||||
},
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 3.84,
|
||||
elevation: 5,
|
||||
},
|
||||
cardTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: "bold",
|
||||
marginBottom: 20,
|
||||
textAlign: "center",
|
||||
},
|
||||
tabContainer: {
|
||||
flexDirection: "row",
|
||||
marginBottom: 20,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: "#e2e8f0",
|
||||
},
|
||||
tabButton: {
|
||||
flex: 1,
|
||||
paddingVertical: 10,
|
||||
alignItems: "center",
|
||||
},
|
||||
tabText: {
|
||||
fontSize: 16,
|
||||
color: "#718096",
|
||||
},
|
||||
activeTab: {
|
||||
borderBottomWidth: 2,
|
||||
borderBottomColor: "#6366f1",
|
||||
},
|
||||
activeTabText: {
|
||||
color: "#6366f1",
|
||||
fontWeight: "600",
|
||||
},
|
||||
form: {
|
||||
width: "100%",
|
||||
},
|
||||
inputGroup: {
|
||||
marginBottom: 15,
|
||||
},
|
||||
label: {
|
||||
fontSize: 16,
|
||||
marginBottom: 5,
|
||||
color: "#333",
|
||||
},
|
||||
input: {
|
||||
height: 45,
|
||||
borderWidth: 1,
|
||||
borderColor: "#ddd",
|
||||
borderRadius: 5,
|
||||
paddingHorizontal: 10,
|
||||
fontSize: 16,
|
||||
backgroundColor: "#fafafa",
|
||||
},
|
||||
errorText: {
|
||||
color: "#e53e3e",
|
||||
marginBottom: 10,
|
||||
},
|
||||
successText: {
|
||||
color: "#38a169",
|
||||
fontSize: 16,
|
||||
textAlign: "center",
|
||||
marginBottom: 15,
|
||||
},
|
||||
messageContainer: {
|
||||
alignItems: "center",
|
||||
paddingVertical: 10,
|
||||
},
|
||||
button: {
|
||||
backgroundColor: "#6366f1",
|
||||
paddingVertical: 12,
|
||||
borderRadius: 5,
|
||||
alignItems: "center",
|
||||
marginTop: 10,
|
||||
},
|
||||
buttonText: {
|
||||
color: "#fff",
|
||||
fontSize: 16,
|
||||
fontWeight: "600",
|
||||
},
|
||||
secondaryButton: {
|
||||
backgroundColor: "#e2e8f0",
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 20,
|
||||
borderRadius: 5,
|
||||
alignItems: "center",
|
||||
marginTop: 10,
|
||||
},
|
||||
secondaryButtonText: {
|
||||
color: "#4a5568",
|
||||
fontSize: 16,
|
||||
fontWeight: "600",
|
||||
},
|
||||
footer: {
|
||||
marginTop: 20,
|
||||
alignItems: "center",
|
||||
},
|
||||
footerText: {
|
||||
color: "#666",
|
||||
fontSize: 14,
|
||||
},
|
||||
link: {
|
||||
color: "#6366f1",
|
||||
fontWeight: "bold",
|
||||
},
|
||||
});
|
||||
@@ -1,233 +0,0 @@
|
||||
import { router, useLocalSearchParams } from "expo-router";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
Keyboard,
|
||||
KeyboardAvoidingView,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
TouchableWithoutFeedback,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useAuth } from "../lib/nhost/AuthProvider";
|
||||
|
||||
export default function MFAVerification() {
|
||||
const { nhost } = useAuth();
|
||||
const params = useLocalSearchParams();
|
||||
const ticket = params["ticket"] as string;
|
||||
|
||||
const [verificationCode, setVerificationCode] = useState<string>("");
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Redirect if no ticket is provided
|
||||
useEffect(() => {
|
||||
if (!ticket) {
|
||||
Alert.alert("Error", "Invalid authentication request");
|
||||
router.replace("/signin");
|
||||
}
|
||||
}, [ticket]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!verificationCode || verificationCode.length !== 6) {
|
||||
setError("Please enter a valid 6-digit code");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ticket) {
|
||||
setError("Missing authentication ticket");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Complete MFA verification
|
||||
await nhost.auth.verifySignInMfaTotp({
|
||||
ticket,
|
||||
otp: verificationCode,
|
||||
});
|
||||
} catch (err) {
|
||||
const errMessage =
|
||||
err instanceof Error ? err.message : "An unexpected error occurred";
|
||||
setError(`Verification failed: ${errMessage}`);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
behavior="padding"
|
||||
style={styles.container}
|
||||
keyboardVerticalOffset={40}
|
||||
>
|
||||
<TouchableWithoutFeedback onPress={Keyboard.dismiss}>
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.scrollViewContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
<View style={styles.contentContainer}>
|
||||
<Text style={styles.title}>Multi-Factor Authentication</Text>
|
||||
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.instructions}>
|
||||
Enter the verification code from your authenticator app to
|
||||
complete sign in.
|
||||
</Text>
|
||||
|
||||
{error && (
|
||||
<View style={styles.errorContainer}>
|
||||
<Text style={styles.errorText}>{error}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.inputContainer}>
|
||||
<Text style={styles.label}>Authentication Code</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={verificationCode}
|
||||
onChangeText={setVerificationCode}
|
||||
placeholder="Enter 6-digit code"
|
||||
keyboardType="number-pad"
|
||||
maxLength={6}
|
||||
autoFocus
|
||||
returnKeyType="done"
|
||||
onSubmitEditing={() => {
|
||||
Keyboard.dismiss();
|
||||
if (verificationCode.length === 6 && !isLoading) {
|
||||
void handleSubmit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.button,
|
||||
(isLoading || verificationCode.length !== 6) &&
|
||||
styles.buttonDisabled,
|
||||
]}
|
||||
onPress={handleSubmit}
|
||||
disabled={isLoading || verificationCode.length !== 6}
|
||||
>
|
||||
{isLoading ? (
|
||||
<ActivityIndicator size="small" color="#fff" />
|
||||
) : (
|
||||
<Text style={styles.buttonText}>Verify</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.backLink}
|
||||
onPress={() => router.back()}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Text style={styles.backLinkText}>Back to Sign In</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</TouchableWithoutFeedback>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: "#f5f5f5",
|
||||
},
|
||||
scrollViewContent: {
|
||||
flexGrow: 1,
|
||||
},
|
||||
contentContainer: {
|
||||
flex: 1,
|
||||
padding: 20,
|
||||
justifyContent: "center",
|
||||
paddingBottom: 40,
|
||||
},
|
||||
title: {
|
||||
fontSize: 24,
|
||||
fontWeight: "bold",
|
||||
marginBottom: 20,
|
||||
textAlign: "center",
|
||||
color: "#333",
|
||||
},
|
||||
card: {
|
||||
backgroundColor: "#fff",
|
||||
borderRadius: 10,
|
||||
padding: 20,
|
||||
shadowColor: "#000",
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 2,
|
||||
},
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 3.84,
|
||||
elevation: 5,
|
||||
},
|
||||
instructions: {
|
||||
fontSize: 16,
|
||||
color: "#4b5563",
|
||||
marginBottom: 20,
|
||||
textAlign: "center",
|
||||
},
|
||||
errorContainer: {
|
||||
backgroundColor: "#fee2e2",
|
||||
padding: 12,
|
||||
borderRadius: 6,
|
||||
marginBottom: 16,
|
||||
borderLeftWidth: 4,
|
||||
borderLeftColor: "#ef4444",
|
||||
},
|
||||
errorText: {
|
||||
color: "#b91c1c",
|
||||
},
|
||||
inputContainer: {
|
||||
marginBottom: 20,
|
||||
},
|
||||
label: {
|
||||
fontSize: 16,
|
||||
marginBottom: 8,
|
||||
color: "#374151",
|
||||
},
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderColor: "#d1d5db",
|
||||
borderRadius: 6,
|
||||
padding: 12,
|
||||
fontSize: 18,
|
||||
backgroundColor: "#f9fafb",
|
||||
textAlign: "center",
|
||||
letterSpacing: 8,
|
||||
},
|
||||
button: {
|
||||
backgroundColor: "#6366f1",
|
||||
padding: 15,
|
||||
borderRadius: 6,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
buttonDisabled: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
buttonText: {
|
||||
color: "#fff",
|
||||
fontWeight: "bold",
|
||||
fontSize: 16,
|
||||
},
|
||||
backLink: {
|
||||
marginTop: 20,
|
||||
alignItems: "center",
|
||||
},
|
||||
backLinkText: {
|
||||
color: "#6366f1",
|
||||
fontSize: 16,
|
||||
},
|
||||
});
|
||||
@@ -1,431 +0,0 @@
|
||||
import * as Linking from "expo-linking";
|
||||
import { Link, router, useLocalSearchParams } from "expo-router";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
KeyboardAvoidingView,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import MagicLinkForm from "./components/MagicLinkForm";
|
||||
import NativeLoginForm from "./components/NativeLoginForm";
|
||||
import SocialLoginForm from "./components/SocialLoginForm";
|
||||
import { useAuth } from "./lib/nhost/AuthProvider";
|
||||
|
||||
export default function SignUp() {
|
||||
const { nhost, isAuthenticated } = useAuth();
|
||||
const params = useLocalSearchParams();
|
||||
|
||||
const [email, setEmail] = useState<string>("");
|
||||
const [password, setPassword] = useState<string>("");
|
||||
const [displayName, setDisplayName] = useState<string>("");
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [appleAuthInProgress, setAppleAuthInProgress] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<boolean>(false);
|
||||
const [activeTab, setActiveTab] = useState<
|
||||
"password" | "magic" | "social" | "native"
|
||||
>("password");
|
||||
|
||||
const magicLinkSent = params["magic"] === "success";
|
||||
|
||||
// If already authenticated, redirect to profile
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
router.replace("/profile");
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
setSuccess(false);
|
||||
|
||||
try {
|
||||
const response = await nhost.auth.signUpEmailPassword({
|
||||
email,
|
||||
password,
|
||||
options: {
|
||||
displayName,
|
||||
redirectTo: Linking.createURL("verify"),
|
||||
},
|
||||
});
|
||||
|
||||
if (response.body?.session) {
|
||||
// Successfully signed up and automatically signed in
|
||||
router.replace("/profile");
|
||||
} else {
|
||||
// Verification email sent
|
||||
setSuccess(true);
|
||||
}
|
||||
} catch (err) {
|
||||
const errMessage =
|
||||
err instanceof Error ? err.message : "An unexpected error occurred";
|
||||
setError(`An error occurred during sign up: ${errMessage}`);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Social login is now handled by the SocialLoginForm component
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView behavior="padding" style={styles.container}>
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.scrollContainer}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
<Text style={styles.title}>Nhost SDK Demo</Text>
|
||||
|
||||
<View style={styles.card}>
|
||||
{success ? (
|
||||
<>
|
||||
<Text style={styles.cardTitle}>Check Your Email</Text>
|
||||
<View style={styles.messageContainer}>
|
||||
<View style={styles.successMessageBox}>
|
||||
<Text style={styles.successText}>
|
||||
We've sent a verification link to{" "}
|
||||
<Text style={styles.emailText}>{email}</Text>
|
||||
</Text>
|
||||
<Text style={styles.successText}>
|
||||
Please check your email and click the verification link to
|
||||
activate your account.
|
||||
</Text>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
style={styles.button}
|
||||
onPress={() => router.replace("/signin")}
|
||||
>
|
||||
<Text style={styles.buttonText}>Back to Sign In</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text style={styles.cardTitle}>Sign Up</Text>
|
||||
|
||||
{magicLinkSent ? (
|
||||
<View style={styles.messageContainer}>
|
||||
<Text style={styles.successText}>
|
||||
Magic link sent! Check your email to sign in.
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.secondaryButton}
|
||||
onPress={() => router.setParams({ magic: "" })}
|
||||
>
|
||||
<Text style={styles.secondaryButtonText}>
|
||||
Back to sign up
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
<View style={styles.tabContainer}>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.tabButton,
|
||||
activeTab === "password" && styles.activeTab,
|
||||
]}
|
||||
onPress={() => setActiveTab("password")}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.tabText,
|
||||
activeTab === "password" && styles.activeTabText,
|
||||
]}
|
||||
>
|
||||
Password
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.tabButton,
|
||||
activeTab === "magic" && styles.activeTab,
|
||||
]}
|
||||
onPress={() => setActiveTab("magic")}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.tabText,
|
||||
activeTab === "magic" && styles.activeTabText,
|
||||
]}
|
||||
>
|
||||
Magic Link
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.tabButton,
|
||||
activeTab === "social" && styles.activeTab,
|
||||
]}
|
||||
onPress={() => setActiveTab("social")}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.tabText,
|
||||
activeTab === "social" && styles.activeTabText,
|
||||
]}
|
||||
>
|
||||
Social
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.tabButton,
|
||||
activeTab === "native" && styles.activeTab,
|
||||
]}
|
||||
onPress={() => setActiveTab("native")}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.tabText,
|
||||
activeTab === "native" && styles.activeTabText,
|
||||
]}
|
||||
>
|
||||
Native
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={styles.form}>
|
||||
{activeTab === "password" ? (
|
||||
<>
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.label}>Display Name</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={displayName}
|
||||
onChangeText={setDisplayName}
|
||||
placeholder="Enter your name"
|
||||
autoCapitalize="words"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.label}>Email</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
placeholder="Enter your email"
|
||||
keyboardType="email-address"
|
||||
autoCapitalize="none"
|
||||
autoComplete="email"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.label}>Password</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
placeholder="Enter your password"
|
||||
secureTextEntry
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
<Text style={styles.helperText}>
|
||||
Password must be at least 8 characters long
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{error && <Text style={styles.errorText}>{error}</Text>}
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.button}
|
||||
onPress={handleSubmit}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<ActivityIndicator size="small" color="#fff" />
|
||||
) : (
|
||||
<Text style={styles.buttonText}>Sign Up</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</>
|
||||
) : activeTab === "magic" ? (
|
||||
<MagicLinkForm buttonLabel="Sign Up with Magic Link" />
|
||||
) : activeTab === "social" ? (
|
||||
<SocialLoginForm action="Sign Up" isLoading={isLoading} />
|
||||
) : (
|
||||
<NativeLoginForm
|
||||
action="Sign Up"
|
||||
isLoading={isLoading || appleAuthInProgress}
|
||||
setAppleAuthInProgress={setAppleAuthInProgress}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.footer}>
|
||||
<Text style={styles.footerText}>
|
||||
Already have an account?{" "}
|
||||
<Link href="/signin" style={styles.link}>
|
||||
Sign In
|
||||
</Link>
|
||||
</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: "#f5f5f5",
|
||||
},
|
||||
scrollContainer: {
|
||||
flexGrow: 1,
|
||||
justifyContent: "center",
|
||||
padding: 20,
|
||||
},
|
||||
title: {
|
||||
fontSize: 24,
|
||||
fontWeight: "bold",
|
||||
textAlign: "center",
|
||||
marginBottom: 20,
|
||||
color: "#333",
|
||||
},
|
||||
card: {
|
||||
width: "100%",
|
||||
maxWidth: 400,
|
||||
backgroundColor: "#fff",
|
||||
borderRadius: 10,
|
||||
padding: 20,
|
||||
alignSelf: "center",
|
||||
shadowColor: "#000",
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 2,
|
||||
},
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 3.84,
|
||||
elevation: 5,
|
||||
},
|
||||
cardTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: "bold",
|
||||
marginBottom: 20,
|
||||
textAlign: "center",
|
||||
},
|
||||
tabContainer: {
|
||||
flexDirection: "row",
|
||||
marginBottom: 20,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: "#e2e8f0",
|
||||
},
|
||||
tabButton: {
|
||||
flex: 1,
|
||||
paddingVertical: 10,
|
||||
alignItems: "center",
|
||||
},
|
||||
tabText: {
|
||||
fontSize: 16,
|
||||
color: "#718096",
|
||||
},
|
||||
activeTab: {
|
||||
borderBottomWidth: 2,
|
||||
borderBottomColor: "#6366f1",
|
||||
},
|
||||
activeTabText: {
|
||||
color: "#6366f1",
|
||||
fontWeight: "600",
|
||||
},
|
||||
form: {
|
||||
width: "100%",
|
||||
},
|
||||
inputGroup: {
|
||||
marginBottom: 15,
|
||||
},
|
||||
label: {
|
||||
fontSize: 16,
|
||||
marginBottom: 5,
|
||||
color: "#333",
|
||||
},
|
||||
input: {
|
||||
height: 45,
|
||||
borderWidth: 1,
|
||||
borderColor: "#ddd",
|
||||
borderRadius: 5,
|
||||
paddingHorizontal: 10,
|
||||
fontSize: 16,
|
||||
backgroundColor: "#fafafa",
|
||||
},
|
||||
helperText: {
|
||||
fontSize: 12,
|
||||
color: "#666",
|
||||
marginTop: 3,
|
||||
},
|
||||
errorText: {
|
||||
color: "#e53e3e",
|
||||
marginBottom: 10,
|
||||
},
|
||||
successText: {
|
||||
color: "#38a169",
|
||||
fontSize: 16,
|
||||
textAlign: "center",
|
||||
marginBottom: 15,
|
||||
},
|
||||
successMessageBox: {
|
||||
backgroundColor: "#f0fff4",
|
||||
borderColor: "#38a169",
|
||||
borderWidth: 1,
|
||||
borderRadius: 8,
|
||||
padding: 16,
|
||||
marginBottom: 20,
|
||||
},
|
||||
emailText: {
|
||||
fontWeight: "bold",
|
||||
color: "#2d3748",
|
||||
},
|
||||
messageContainer: {
|
||||
alignItems: "center",
|
||||
paddingVertical: 10,
|
||||
},
|
||||
button: {
|
||||
backgroundColor: "#6366f1",
|
||||
paddingVertical: 12,
|
||||
borderRadius: 5,
|
||||
alignItems: "center",
|
||||
marginTop: 10,
|
||||
},
|
||||
buttonText: {
|
||||
color: "#fff",
|
||||
fontSize: 16,
|
||||
fontWeight: "600",
|
||||
},
|
||||
secondaryButton: {
|
||||
backgroundColor: "#e2e8f0",
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 20,
|
||||
borderRadius: 5,
|
||||
alignItems: "center",
|
||||
marginTop: 10,
|
||||
},
|
||||
secondaryButtonText: {
|
||||
color: "#4a5568",
|
||||
fontSize: 16,
|
||||
fontWeight: "600",
|
||||
},
|
||||
footer: {
|
||||
marginTop: 20,
|
||||
alignItems: "center",
|
||||
},
|
||||
footerText: {
|
||||
color: "#666",
|
||||
fontSize: 14,
|
||||
},
|
||||
link: {
|
||||
color: "#6366f1",
|
||||
fontWeight: "bold",
|
||||
},
|
||||
});
|
||||
@@ -1,552 +0,0 @@
|
||||
import type { FetchError } from "@nhost/nhost-js/fetch";
|
||||
import type { ErrorResponse, FileMetadata } from "@nhost/nhost-js/storage";
|
||||
import * as DocumentPicker from "expo-document-picker";
|
||||
import * as FileSystem from "expo-file-system";
|
||||
import { Stack } from "expo-router";
|
||||
import * as Sharing from "expo-sharing";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
FlatList,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import ProtectedScreen from "./components/ProtectedScreen";
|
||||
import { useAuth } from "./lib/nhost/AuthProvider";
|
||||
import { blobToBase64, formatFileSize } from "./lib/utils";
|
||||
|
||||
interface DeleteStatus {
|
||||
message: string;
|
||||
isError: boolean;
|
||||
}
|
||||
|
||||
interface GraphqlGetFilesResponse {
|
||||
files: FileMetadata[];
|
||||
}
|
||||
|
||||
export default function Upload() {
|
||||
const { nhost } = useAuth();
|
||||
const [selectedFile, setSelectedFile] =
|
||||
useState<DocumentPicker.DocumentPickerResult | null>(null);
|
||||
const [uploading, setUploading] = useState<boolean>(false);
|
||||
const [uploadResult, setUploadResult] = useState<FileMetadata | null>(null);
|
||||
const [isFetching, setIsFetching] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [files, setFiles] = useState<FileMetadata[]>([]);
|
||||
const [viewingFile, setViewingFile] = useState<string | null>(null);
|
||||
const [deleting, setDeleting] = useState<string | null>(null);
|
||||
const [deleteStatus, setDeleteStatus] = useState<DeleteStatus | null>(null);
|
||||
|
||||
const fetchFiles = useCallback(async () => {
|
||||
setIsFetching(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Fetch files using GraphQL query
|
||||
const response = await nhost.graphql.request<GraphqlGetFilesResponse>({
|
||||
query: `query GetFiles {
|
||||
files {
|
||||
id
|
||||
name
|
||||
size
|
||||
mimeType
|
||||
bucketId
|
||||
uploadedByUserId
|
||||
}
|
||||
}`,
|
||||
});
|
||||
|
||||
setFiles(response.body.data?.files || []);
|
||||
} catch (err) {
|
||||
const errMessage =
|
||||
err instanceof Error ? err.message : "An unexpected error occurred";
|
||||
setError(`Failed to fetch files: ${errMessage}`);
|
||||
} finally {
|
||||
setIsFetching(false);
|
||||
}
|
||||
}, [nhost.graphql]);
|
||||
|
||||
// Fetch existing files when component mounts
|
||||
useEffect(() => {
|
||||
void fetchFiles();
|
||||
}, [fetchFiles]);
|
||||
|
||||
const pickDocument = async () => {
|
||||
try {
|
||||
const result = await DocumentPicker.getDocumentAsync({
|
||||
type: "*/*", // All file types
|
||||
copyToCacheDirectory: true,
|
||||
});
|
||||
|
||||
if (!result.canceled) {
|
||||
setSelectedFile(result);
|
||||
setError(null);
|
||||
setUploadResult(null);
|
||||
}
|
||||
} catch (err) {
|
||||
setError("Failed to pick document");
|
||||
console.error("DocumentPicker Error:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!selectedFile || selectedFile.canceled) {
|
||||
setError("Please select a file to upload");
|
||||
return;
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// For React Native, we need to read the file first
|
||||
const fileToUpload = selectedFile.assets?.[0];
|
||||
if (!fileToUpload) {
|
||||
throw new Error("No file selected");
|
||||
}
|
||||
|
||||
const file: unknown = {
|
||||
uri: fileToUpload.uri,
|
||||
name: fileToUpload.name || "file",
|
||||
type: fileToUpload.mimeType || "application/octet-stream",
|
||||
};
|
||||
// Upload file using Nhost storage
|
||||
const response = await nhost.storage.uploadFiles({
|
||||
"bucket-id": "default",
|
||||
"file[]": [file as File],
|
||||
});
|
||||
|
||||
// Get the processed file data
|
||||
const uploadedFile = response.body.processedFiles?.[0];
|
||||
if (uploadedFile === undefined) {
|
||||
throw new Error("Failed to upload file");
|
||||
}
|
||||
|
||||
setUploadResult(uploadedFile);
|
||||
|
||||
// Reset form
|
||||
setSelectedFile(null);
|
||||
|
||||
// Update files list
|
||||
setFiles((prevFiles) => [uploadedFile, ...prevFiles]);
|
||||
|
||||
// Refresh file list
|
||||
await fetchFiles();
|
||||
|
||||
// Clear success message after 3 seconds
|
||||
setTimeout(() => {
|
||||
setUploadResult(null);
|
||||
}, 3000);
|
||||
} catch (err: unknown) {
|
||||
const error = err as FetchError<ErrorResponse>;
|
||||
setError(`Failed to upload file: ${error.message}`);
|
||||
console.error("Upload error:", err);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Function to handle viewing a file with proper authorization
|
||||
const handleViewFile = async (
|
||||
fileId: string,
|
||||
fileName: string,
|
||||
mimeType: string,
|
||||
) => {
|
||||
setViewingFile(fileId);
|
||||
|
||||
try {
|
||||
// Fetch the file with authentication using the SDK
|
||||
const response = await nhost.storage.getFile(fileId);
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error("Failed to retrieve file contents");
|
||||
}
|
||||
|
||||
// For iOS/Android, we need to save the file to the device first
|
||||
// Create a unique temp file path with a timestamp to prevent collisions
|
||||
const fileExtension = fileName.includes(".") ? "" : ".file";
|
||||
const tempFileName = fileName.includes(".")
|
||||
? fileName
|
||||
: `${fileName}${fileExtension}`;
|
||||
const tempFilePath = `${FileSystem.cacheDirectory}${Date.now()}_${tempFileName}`;
|
||||
|
||||
// Get the blob from the response
|
||||
const blob = response.body;
|
||||
|
||||
// Convert blob to base64
|
||||
const base64Data = await blobToBase64(blob);
|
||||
|
||||
// Write the file to the filesystem
|
||||
await FileSystem.writeAsStringAsync(tempFilePath, base64Data, {
|
||||
encoding: FileSystem.EncodingType.Base64,
|
||||
});
|
||||
|
||||
// Check if sharing is available (iOS & Android)
|
||||
const isSharingAvailable = await Sharing.isAvailableAsync();
|
||||
|
||||
if (isSharingAvailable) {
|
||||
// Open the file with the default app
|
||||
await Sharing.shareAsync(tempFilePath, {
|
||||
mimeType: mimeType || "application/octet-stream",
|
||||
dialogTitle: `View ${fileName}`,
|
||||
UTI: mimeType, // for iOS
|
||||
});
|
||||
} else {
|
||||
throw new Error("Sharing is not available on this device");
|
||||
}
|
||||
} catch (err) {
|
||||
const error = err as FetchError<ErrorResponse>;
|
||||
setError(`Failed to view file: ${error.message}`);
|
||||
console.error("Error viewing file:", err);
|
||||
Alert.alert("Error", `Failed to view file: ${error.message}`);
|
||||
} finally {
|
||||
setViewingFile(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Function to handle deleting a file
|
||||
const handleDeleteFile = (fileId: string) => {
|
||||
if (!fileId || deleting) return;
|
||||
|
||||
// Confirm deletion
|
||||
Alert.alert("Delete File", "Are you sure you want to delete this file?", [
|
||||
{
|
||||
text: "Cancel",
|
||||
style: "cancel",
|
||||
},
|
||||
{
|
||||
text: "Delete",
|
||||
style: "destructive",
|
||||
onPress: () => {
|
||||
void (async () => {
|
||||
setDeleting(fileId);
|
||||
setError(null);
|
||||
setDeleteStatus(null);
|
||||
|
||||
// Get the file name for the status message
|
||||
const fileToDelete = files.find((file) => file.id === fileId);
|
||||
const fileName = fileToDelete?.name || "File";
|
||||
|
||||
try {
|
||||
// Delete the file using the Nhost storage SDK
|
||||
await nhost.storage.deleteFile(fileId);
|
||||
|
||||
// Show success message
|
||||
setDeleteStatus({
|
||||
message: `${fileName} deleted successfully`,
|
||||
isError: false,
|
||||
});
|
||||
|
||||
// Update the local files list by removing the deleted file
|
||||
setFiles(files.filter((file) => file.id !== fileId));
|
||||
|
||||
// Refresh the file list
|
||||
await fetchFiles();
|
||||
|
||||
// Clear the success message after 3 seconds
|
||||
setTimeout(() => {
|
||||
setDeleteStatus(null);
|
||||
}, 3000);
|
||||
} catch (err) {
|
||||
// Show error message
|
||||
const error = err as FetchError<ErrorResponse>;
|
||||
setDeleteStatus({
|
||||
message: `Failed to delete ${fileName}: ${error.message}`,
|
||||
isError: true,
|
||||
});
|
||||
console.error("Error deleting file:", err);
|
||||
} finally {
|
||||
setDeleting(null);
|
||||
}
|
||||
})();
|
||||
},
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
return (
|
||||
<ProtectedScreen>
|
||||
<Stack.Screen options={{ title: "File Upload" }} />
|
||||
<View style={styles.container}>
|
||||
{/* Upload Form */}
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.title}>Upload a File</Text>
|
||||
|
||||
<TouchableOpacity style={styles.fileUpload} onPress={pickDocument}>
|
||||
<View style={styles.uploadIcon}>
|
||||
<Text style={styles.uploadIconText}>⬆️</Text>
|
||||
</View>
|
||||
<Text style={styles.uploadText}>Tap to select a file</Text>
|
||||
{selectedFile &&
|
||||
!selectedFile.canceled &&
|
||||
selectedFile.assets?.[0] && (
|
||||
<Text style={styles.fileName}>
|
||||
{selectedFile.assets[0].name}(
|
||||
{formatFileSize(selectedFile.assets[0].size || 0)})
|
||||
</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
{error && (
|
||||
<View style={styles.errorContainer}>
|
||||
<Text style={styles.errorText}>{error}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{uploadResult && (
|
||||
<View style={styles.successContainer}>
|
||||
<Text style={styles.successText}>
|
||||
File uploaded successfully!
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.button,
|
||||
(!selectedFile || selectedFile.canceled || uploading) &&
|
||||
styles.buttonDisabled,
|
||||
]}
|
||||
onPress={handleUpload}
|
||||
disabled={!selectedFile || selectedFile.canceled || uploading}
|
||||
>
|
||||
<Text style={styles.buttonText}>
|
||||
{uploading ? "Uploading..." : "Upload File"}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Files List */}
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.title}>Your Files</Text>
|
||||
|
||||
{deleteStatus && (
|
||||
<View
|
||||
style={[
|
||||
styles.statusContainer,
|
||||
deleteStatus.isError
|
||||
? styles.errorContainer
|
||||
: styles.successContainer,
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={
|
||||
deleteStatus.isError ? styles.errorText : styles.successText
|
||||
}
|
||||
>
|
||||
{deleteStatus.message}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{isFetching ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color="#0000ff" />
|
||||
<Text style={styles.loadingText}>Loading files...</Text>
|
||||
</View>
|
||||
) : files.length === 0 ? (
|
||||
<Text style={styles.emptyText}>No files uploaded yet.</Text>
|
||||
) : (
|
||||
<FlatList
|
||||
data={files}
|
||||
keyExtractor={(item) => item.id || Math.random().toString()}
|
||||
renderItem={({ item }) => (
|
||||
<View style={styles.fileItem}>
|
||||
<View style={styles.fileInfo}>
|
||||
<Text style={styles.fileNameText} numberOfLines={1}>
|
||||
{item.name}
|
||||
</Text>
|
||||
<Text style={styles.fileDetails}>
|
||||
{item.mimeType} • {formatFileSize(item.size || 0)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.fileActions}>
|
||||
<TouchableOpacity
|
||||
style={styles.actionButton}
|
||||
onPress={() =>
|
||||
handleViewFile(
|
||||
item.id || "unknown",
|
||||
item.name || "unknown",
|
||||
item.mimeType || "unknown",
|
||||
)
|
||||
}
|
||||
disabled={viewingFile === item.id}
|
||||
>
|
||||
{viewingFile === item.id ? (
|
||||
<Text style={styles.actionText}>⌛</Text>
|
||||
) : (
|
||||
<Text style={styles.actionText}>👁️</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.actionButton, styles.deleteButton]}
|
||||
onPress={() => handleDeleteFile(item.id || "unknown")}
|
||||
disabled={deleting === item.id}
|
||||
>
|
||||
{deleting === item.id ? (
|
||||
<Text style={styles.actionText}>⌛</Text>
|
||||
) : (
|
||||
<Text style={styles.actionText}>🗑️</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
style={styles.fileList}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</ProtectedScreen>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
padding: 16,
|
||||
backgroundColor: "#f5f5f5",
|
||||
},
|
||||
card: {
|
||||
backgroundColor: "#fff",
|
||||
borderRadius: 10,
|
||||
padding: 20,
|
||||
marginBottom: 20,
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 3,
|
||||
},
|
||||
title: {
|
||||
fontSize: 20,
|
||||
fontWeight: "bold",
|
||||
marginBottom: 16,
|
||||
color: "#333",
|
||||
},
|
||||
fileUpload: {
|
||||
borderWidth: 2,
|
||||
borderColor: "#ddd",
|
||||
borderStyle: "dashed",
|
||||
borderRadius: 10,
|
||||
padding: 20,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: "#f9f9f9",
|
||||
marginBottom: 16,
|
||||
},
|
||||
uploadIcon: {
|
||||
marginBottom: 10,
|
||||
},
|
||||
uploadIconText: {
|
||||
fontSize: 24,
|
||||
},
|
||||
uploadText: {
|
||||
fontSize: 16,
|
||||
color: "#666",
|
||||
},
|
||||
fileName: {
|
||||
marginTop: 8,
|
||||
color: "#0066cc",
|
||||
fontSize: 14,
|
||||
},
|
||||
button: {
|
||||
backgroundColor: "#0066cc",
|
||||
padding: 15,
|
||||
borderRadius: 8,
|
||||
alignItems: "center",
|
||||
},
|
||||
buttonDisabled: {
|
||||
backgroundColor: "#ccc",
|
||||
},
|
||||
buttonText: {
|
||||
color: "white",
|
||||
fontWeight: "bold",
|
||||
fontSize: 16,
|
||||
},
|
||||
errorContainer: {
|
||||
backgroundColor: "#ffebee",
|
||||
padding: 10,
|
||||
borderRadius: 8,
|
||||
marginBottom: 16,
|
||||
borderLeftWidth: 4,
|
||||
borderLeftColor: "#f44336",
|
||||
},
|
||||
errorText: {
|
||||
color: "#d32f2f",
|
||||
},
|
||||
successContainer: {
|
||||
backgroundColor: "#e8f5e9",
|
||||
padding: 10,
|
||||
borderRadius: 8,
|
||||
marginBottom: 16,
|
||||
borderLeftWidth: 4,
|
||||
borderLeftColor: "#4caf50",
|
||||
},
|
||||
successText: {
|
||||
color: "#2e7d32",
|
||||
},
|
||||
statusContainer: {
|
||||
padding: 10,
|
||||
borderRadius: 8,
|
||||
marginBottom: 16,
|
||||
borderLeftWidth: 4,
|
||||
},
|
||||
loadingContainer: {
|
||||
alignItems: "center",
|
||||
padding: 20,
|
||||
},
|
||||
loadingText: {
|
||||
marginTop: 10,
|
||||
color: "#666",
|
||||
},
|
||||
emptyText: {
|
||||
textAlign: "center",
|
||||
color: "#666",
|
||||
padding: 20,
|
||||
},
|
||||
fileList: {
|
||||
maxHeight: 300,
|
||||
},
|
||||
fileItem: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 8,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: "#eee",
|
||||
},
|
||||
fileInfo: {
|
||||
flex: 1,
|
||||
paddingRight: 10,
|
||||
},
|
||||
fileNameText: {
|
||||
fontSize: 16,
|
||||
fontWeight: "500",
|
||||
color: "#333",
|
||||
marginBottom: 4,
|
||||
},
|
||||
fileDetails: {
|
||||
fontSize: 12,
|
||||
color: "#777",
|
||||
},
|
||||
fileActions: {
|
||||
flexDirection: "row",
|
||||
},
|
||||
actionButton: {
|
||||
padding: 8,
|
||||
marginHorizontal: 4,
|
||||
borderRadius: 20,
|
||||
backgroundColor: "#f0f0f0",
|
||||
},
|
||||
deleteButton: {
|
||||
backgroundColor: "#fff0f0",
|
||||
},
|
||||
actionText: {
|
||||
fontSize: 16,
|
||||
},
|
||||
});
|
||||
@@ -1,265 +0,0 @@
|
||||
import { router, useLocalSearchParams } from "expo-router";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useAuth } from "./lib/nhost/AuthProvider";
|
||||
|
||||
export default function Verify() {
|
||||
const params = useLocalSearchParams<{ refreshToken: string }>();
|
||||
const [status, setStatus] = useState<"verifying" | "success" | "error">(
|
||||
"verifying",
|
||||
);
|
||||
const [error, setError] = useState<string>("");
|
||||
|
||||
const { nhost, isAuthenticated } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
const refreshToken = params.refreshToken;
|
||||
|
||||
if (!refreshToken) {
|
||||
setStatus("error");
|
||||
setError("No refresh token found in the link");
|
||||
return;
|
||||
}
|
||||
|
||||
// Flag to handle component unmounting during async operations
|
||||
let isMounted = true;
|
||||
|
||||
async function processToken(): Promise<void> {
|
||||
try {
|
||||
// First display the verifying message for at least a moment
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
if (!isMounted) return;
|
||||
|
||||
if (!refreshToken) {
|
||||
// Collect all URL parameters to display
|
||||
const allParams: Record<string, string> = {};
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (typeof value === "string") {
|
||||
allParams[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
setStatus("error");
|
||||
setError("No refresh token found in the link");
|
||||
return;
|
||||
}
|
||||
|
||||
// Process the token
|
||||
await nhost.auth.refreshToken({ refreshToken });
|
||||
|
||||
if (!isMounted) return;
|
||||
|
||||
setStatus("success");
|
||||
|
||||
// Wait to show success message briefly, then redirect
|
||||
setTimeout(() => {
|
||||
if (isMounted) router.replace("/profile");
|
||||
}, 1500);
|
||||
} catch (err) {
|
||||
if (!isMounted) return;
|
||||
|
||||
const errMessage =
|
||||
err instanceof Error ? err.message : "An unexpected error occurred";
|
||||
|
||||
setStatus("error");
|
||||
setError(`An error occurred during verification: ${errMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
void processToken();
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [params, nhost.auth]);
|
||||
|
||||
// If already authenticated and not handling verification, redirect to profile
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && status !== "verifying") {
|
||||
router.replace("/profile");
|
||||
}
|
||||
}, [isAuthenticated, status]);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>Nhost SDK Demo</Text>
|
||||
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.cardTitle}>Email Verification</Text>
|
||||
|
||||
<View style={styles.contentContainer}>
|
||||
{status === "verifying" && (
|
||||
<View>
|
||||
<Text style={styles.statusText}>Verifying your email...</Text>
|
||||
<ActivityIndicator
|
||||
size="large"
|
||||
color="#6366f1"
|
||||
style={styles.spinner}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{status === "success" && (
|
||||
<View>
|
||||
<Text style={styles.successText}>✓ Successfully verified!</Text>
|
||||
<Text style={styles.statusText}>
|
||||
You'll be redirected to your profile page shortly...
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{status === "error" && (
|
||||
<View>
|
||||
<Text style={styles.errorText}>Verification failed</Text>
|
||||
<Text style={styles.statusText}>{error}</Text>
|
||||
|
||||
<View style={styles.debugInfo}>
|
||||
<Text style={styles.debugTitle}>Testing in Expo Go?</Text>
|
||||
<Text style={styles.debugText}>
|
||||
Make sure your magic link uses the proper Expo Go format.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={() => router.replace("/signin")}
|
||||
style={styles.button}
|
||||
>
|
||||
<Text style={styles.buttonText}>Back to Sign In</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
debugInfo: {
|
||||
backgroundColor: "#fff8dc",
|
||||
padding: 10,
|
||||
borderRadius: 5,
|
||||
marginVertical: 10,
|
||||
borderWidth: 1,
|
||||
borderColor: "#ffd700",
|
||||
},
|
||||
debugTitle: {
|
||||
fontWeight: "bold",
|
||||
marginBottom: 5,
|
||||
color: "#b8860b",
|
||||
},
|
||||
debugText: {
|
||||
color: "#5a4a00",
|
||||
fontSize: 14,
|
||||
},
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: "#f5f5f5",
|
||||
justifyContent: "center",
|
||||
padding: 20,
|
||||
},
|
||||
title: {
|
||||
fontSize: 24,
|
||||
fontWeight: "bold",
|
||||
textAlign: "center",
|
||||
marginBottom: 20,
|
||||
color: "#333",
|
||||
},
|
||||
card: {
|
||||
width: "100%",
|
||||
maxWidth: 400,
|
||||
backgroundColor: "#fff",
|
||||
borderRadius: 10,
|
||||
padding: 20,
|
||||
alignSelf: "center",
|
||||
shadowColor: "#000",
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 2,
|
||||
},
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 3.84,
|
||||
elevation: 5,
|
||||
},
|
||||
cardTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: "bold",
|
||||
marginBottom: 20,
|
||||
textAlign: "center",
|
||||
},
|
||||
contentContainer: {
|
||||
alignItems: "center",
|
||||
paddingVertical: 20,
|
||||
},
|
||||
statusText: {
|
||||
fontSize: 16,
|
||||
textAlign: "center",
|
||||
marginBottom: 15,
|
||||
color: "#4a5568",
|
||||
},
|
||||
spinner: {
|
||||
marginVertical: 20,
|
||||
},
|
||||
successText: {
|
||||
color: "#38a169",
|
||||
fontSize: 18,
|
||||
fontWeight: "bold",
|
||||
textAlign: "center",
|
||||
marginBottom: 10,
|
||||
},
|
||||
errorText: {
|
||||
color: "#e53e3e",
|
||||
fontSize: 18,
|
||||
fontWeight: "bold",
|
||||
textAlign: "center",
|
||||
marginBottom: 10,
|
||||
},
|
||||
paramsContainer: {
|
||||
backgroundColor: "#f7fafc",
|
||||
borderRadius: 5,
|
||||
padding: 10,
|
||||
marginVertical: 15,
|
||||
width: "100%",
|
||||
maxHeight: 150,
|
||||
},
|
||||
paramsTitle: {
|
||||
fontWeight: "bold",
|
||||
marginBottom: 5,
|
||||
color: "#2d3748",
|
||||
},
|
||||
paramRow: {
|
||||
flexDirection: "row",
|
||||
marginBottom: 5,
|
||||
},
|
||||
paramKey: {
|
||||
color: "#4299e1",
|
||||
marginRight: 5,
|
||||
fontFamily: "monospace",
|
||||
},
|
||||
paramValue: {
|
||||
flex: 1,
|
||||
fontFamily: "monospace",
|
||||
color: "#2d3748",
|
||||
},
|
||||
button: {
|
||||
backgroundColor: "#6366f1",
|
||||
paddingVertical: 12,
|
||||
borderRadius: 5,
|
||||
alignItems: "center",
|
||||
marginTop: 15,
|
||||
width: "100%",
|
||||
},
|
||||
buttonText: {
|
||||
color: "#fff",
|
||||
fontSize: 16,
|
||||
fontWeight: "600",
|
||||
},
|
||||
});
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 17 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 17 KiB |
@@ -1,64 +0,0 @@
|
||||
{
|
||||
"name": "reactnativewebdemo",
|
||||
"main": "expo-router/entry",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"reset-project": "node ./scripts/reset-project.js",
|
||||
"generate": "echo 'Nothing to do'",
|
||||
"test": "pnpm test:typecheck && pnpm test:lint",
|
||||
"test:typecheck": "tsc --noEmit",
|
||||
"test:lint": "biome check",
|
||||
"format": "biome format --write",
|
||||
"build": "pnpm expo export -p ios -p android",
|
||||
"android": "expo start --android",
|
||||
"ios": "expo start --ios"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/vector-icons": "^14.1.0",
|
||||
"@nhost/nhost-js": "workspace:*",
|
||||
"@react-native-async-storage/async-storage": "^2.1.2",
|
||||
"@react-navigation/bottom-tabs": "^7.3.14",
|
||||
"@react-navigation/elements": "^2.4.3",
|
||||
"@react-navigation/native": "^7.1.10",
|
||||
"expo": "~53.0.10",
|
||||
"expo-apple-authentication": "~7.2.4",
|
||||
"expo-blur": "~14.1.5",
|
||||
"expo-clipboard": "^7.1.4",
|
||||
"expo-constants": "~17.1.6",
|
||||
"expo-crypto": "~14.1.4",
|
||||
"expo-document-picker": "^13.1.5",
|
||||
"expo-file-system": "^18.1.10",
|
||||
"expo-font": "~13.3.1",
|
||||
"expo-haptics": "~14.1.4",
|
||||
"expo-image": "~2.2.0",
|
||||
"expo-linking": "~7.1.5",
|
||||
"expo-router": "~5.0.7",
|
||||
"expo-sharing": "^13.1.5",
|
||||
"expo-splash-screen": "~0.30.9",
|
||||
"expo-status-bar": "~2.2.3",
|
||||
"expo-symbols": "~0.4.5",
|
||||
"expo-system-ui": "~5.0.8",
|
||||
"expo-web-browser": "~14.1.6",
|
||||
"react": "19.0.0",
|
||||
"react-native": "0.79.3",
|
||||
"react-native-gesture-handler": "~2.24.0",
|
||||
"react-native-reanimated": "~3.17.5",
|
||||
"react-native-safe-area-context": "5.4.0",
|
||||
"react-native-screens": "^4.11.1",
|
||||
"react-native-webview": "13.13.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.27.4",
|
||||
"@types/react": "~19.0.14",
|
||||
"@types/node": "^22.15.17"
|
||||
},
|
||||
"private": true,
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"js-yaml@<=4.1.0": ">=4.1.1",
|
||||
"glob@>=10.3.7 <=11.0.3": ">=11.1.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
6275
examples/demos/ReactNativeDemo/pnpm-lock.yaml
generated
6275
examples/demos/ReactNativeDemo/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"extends": [
|
||||
"expo/tsconfig.base",
|
||||
"../../../build/configs/tsconfig/base.json"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
},
|
||||
/* Override specific settings from base.json as needed for React Native */
|
||||
"lib": ["ESNext"],
|
||||
"jsx": "react-native"
|
||||
},
|
||||
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"]
|
||||
}
|
||||
2
examples/demos/backend/.gitignore
vendored
2
examples/demos/backend/.gitignore
vendored
@@ -1,2 +0,0 @@
|
||||
.nhost
|
||||
.secrets
|
||||
@@ -1,16 +0,0 @@
|
||||
GRAFANA_ADMIN_PASSWORD = 'grafana-admin-password'
|
||||
HASURA_GRAPHQL_ADMIN_SECRET = 'nhost-admin-secret'
|
||||
HASURA_GRAPHQL_JWT_SECRET = '55b1d038dff8d4f9a440e848250668527fa5b563700be0dc39e356f1c91f867e'
|
||||
NHOST_WEBHOOK_SECRET = 'nhost-webhook-secret'
|
||||
GITHUB_CLIENT_ID='fixme'
|
||||
GITHUB_CLIENT_SECRET='fixme'
|
||||
APPLE_TEAM_ID='fakeTeamId'
|
||||
APPLE_CLIENT_ID='host.exp.Exponent'
|
||||
APPLE_AUDIENCE='host.exp.Exponent'
|
||||
APPLE_KEY_ID='fakeKeyId'
|
||||
APPLE_PRIVATE_KEY='''-----BEGIN PRIVATE KEY-----
|
||||
MIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQglHTWHjauHnKCxjEP
|
||||
BpMYsTDI2cihQi4tAYHTthj+FF+gCgYIKoZIzj0DAQehRANCAAR30Hs8vTbED10z
|
||||
Qx2m4sJu+lE/ZJsRvDkqLqYF8uh1Tb1g7/KKr8Y7qkK3DmCg72bCyirEq4NVUi2r
|
||||
M/6TYMpw
|
||||
-----END PRIVATE KEY-----'''
|
||||
@@ -1,7 +0,0 @@
|
||||
.PHONY: dev-env-up
|
||||
dev-env-up:
|
||||
@./env-up.sh
|
||||
|
||||
.PHONY: dev-env-down
|
||||
dev-env-down:
|
||||
@nhost down --volumes
|
||||
@@ -1,29 +0,0 @@
|
||||
# backend
|
||||
|
||||
This is a very simple Nhost backend that we will use to demonstrate how to use the various SDKs we are experimenting with. The backend will consist of the following:
|
||||
|
||||
## Database schema
|
||||
|
||||
- A `tasks` table with the following columns:
|
||||
|
||||
- `id` (UUID)
|
||||
- `created_at` (Timestamp)
|
||||
- `updated_at` (Timestamp)
|
||||
- `user_id` (foreigh key to `auth.users.id`)
|
||||
- `title` (Text)
|
||||
- `description` (Text)
|
||||
- `completed` (Boolean)
|
||||
|
||||
- An `attachments` table with the following columns:
|
||||
- `task_id` (foreign key to `tasks.id`)
|
||||
- `file_id` (foreign key to `storage.files.id`)
|
||||
|
||||
Permissions:
|
||||
|
||||
- `tasks`: the `user` role can insert/select/update tasks that they own. Ownership is tracked by the `user_id` column which is set automatically on insert from the session.
|
||||
- `attachments`: the `user` role can insert/select/delete attachments for tasks and files that they own
|
||||
- `storage.files`: the `user` role can insert/select/delete files that they own
|
||||
|
||||
## Functions
|
||||
|
||||
- A `simple` function called `echo` that will just return back some request information
|
||||
@@ -1,8 +0,0 @@
|
||||
#!/bin/sh
|
||||
|
||||
# if .secrets file doesn't exist, cp .secrets.example .secrets
|
||||
if [ ! -f .secrets ]; then
|
||||
cp .secrets.example .secrets
|
||||
fi
|
||||
|
||||
nhost up
|
||||
14
examples/demos/backend/functions/package-lock.json
generated
14
examples/demos/backend/functions/package-lock.json
generated
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"name": "functions",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "functions",
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"devDependencies": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"name": "functions",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"devDependencies": {},
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC"
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"strictNullChecks": false
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
version: 3
|
||||
@@ -1,52 +0,0 @@
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<html dir="ltr" lang="en">
|
||||
<head>
|
||||
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
|
||||
<meta name="x-apple-disable-message-reformatting" />
|
||||
</head>
|
||||
<body style="background-color: #f5f5f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif">
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="max-width: 560px; margin: 20px auto 0 auto; padding: 20px; background-color: #ffffff; border-radius: 8px; border: 1px solid #ececec">
|
||||
<tbody>
|
||||
<tr style="width: 100%">
|
||||
<td>
|
||||
<h1 style="font-size: 24px; letter-spacing: -0.5px; line-height: 1.3; font-weight: 400; color: #484848; margin-top: 0">Потвърдете смяната на вашия имейл</h1>
|
||||
<p style="font-size: 15px; line-height: 1.4; margin: 0 0 10px; color: #3c4149">Използвайте посочения линк, за да повърдите смяната на имейл:</p>
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="padding: 10px 0 0px">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="${link}" style="line-height: 100%; text-decoration: none; display: block; max-width: 100%; background-color: #0052cd; border-radius: 3px; font-weight: 600; color: #fff; font-size: 15px; text-align: center; padding: 11px 23px 11px 23px" target="_blank"
|
||||
><span
|
||||
><!--[if mso]><i style="mso-font-width: 383.33333333333337%; mso-text-raise: 16.5" hidden>   </i><![endif]--></span
|
||||
><span style="max-width: 100%; display: inline-block; line-height: 120%; mso-padding-alt: 0px; mso-text-raise: 8.25px">Смени имейл</span
|
||||
><span
|
||||
><!--[if mso]><i style="mso-font-width: 383.33333333333337%" hidden>   ​</i><![endif]--></span
|
||||
></a
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<hr style="width: 100%; border: none; border-top: 1px solid #eaeaea; border-color: #dfe1e4; margin: 20px 0 20px" />
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tbody style="width: 100%">
|
||||
<tr style="width: 100%">
|
||||
<td data-id="__react-email-column" style="width: 30px"><img alt="Nhost Logo" height="20" src="https://nhost.io/images/emails/icon.png" style="display: block; outline: none; border: none; text-decoration: none; border-radius: 0; width: 20px; height: 20px" width="20" /></td>
|
||||
<td data-id="__react-email-column" style="margin: 0"><a href="https://nhost.io" style="color: #b4becc; text-decoration: none; font-size: 14px" target="_blank">Powered by Nhost</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1 +0,0 @@
|
||||
Потвърждение за смяна на имейл
|
||||
@@ -1,52 +0,0 @@
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<html dir="ltr" lang="en">
|
||||
<head>
|
||||
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
|
||||
<meta name="x-apple-disable-message-reformatting" />
|
||||
</head>
|
||||
<body style="background-color: #f5f5f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif">
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="max-width: 560px; margin: 20px auto 0 auto; padding: 20px; background-color: #ffffff; border-radius: 8px; border: 1px solid #ececec">
|
||||
<tbody>
|
||||
<tr style="width: 100%">
|
||||
<td>
|
||||
<h1 style="font-size: 24px; letter-spacing: -0.5px; line-height: 1.3; font-weight: 400; color: #484848; margin-top: 0">Потвърдете вашия имейл</h1>
|
||||
<p style="font-size: 15px; line-height: 1.4; margin: 0 0 10px; color: #3c4149">Използвайте посочения линк, за да потвърдите вашия имейл:</p>
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="padding: 10px 0 0px">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="${link}" style="line-height: 100%; text-decoration: none; display: block; max-width: 100%; background-color: #0052cd; border-radius: 3px; font-weight: 600; color: #fff; font-size: 15px; text-align: center; padding: 11px 23px 11px 23px" target="_blank"
|
||||
><span
|
||||
><!--[if mso]><i style="mso-font-width: 383.33333333333337%; mso-text-raise: 16.5" hidden>   </i><![endif]--></span
|
||||
><span style="max-width: 100%; display: inline-block; line-height: 120%; mso-padding-alt: 0px; mso-text-raise: 8.25px">Потвърдете имейл</span
|
||||
><span
|
||||
><!--[if mso]><i style="mso-font-width: 383.33333333333337%" hidden>   ​</i><![endif]--></span
|
||||
></a
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<hr style="width: 100%; border: none; border-top: 1px solid #eaeaea; border-color: #dfe1e4; margin: 20px 0 20px" />
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tbody style="width: 100%">
|
||||
<tr style="width: 100%">
|
||||
<td data-id="__react-email-column" style="width: 30px"><img alt="Nhost Logo" height="20" src="https://nhost.io/images/emails/icon.png" style="display: block; outline: none; border: none; text-decoration: none; border-radius: 0; width: 20px; height: 20px" width="20" /></td>
|
||||
<td data-id="__react-email-column" style="margin: 0"><a href="https://nhost.io" style="color: #b4becc; text-decoration: none; font-size: 14px" target="_blank">Powered by Nhost</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1 +0,0 @@
|
||||
Потвърждаване на имейл
|
||||
@@ -1,52 +0,0 @@
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<html dir="ltr" lang="en">
|
||||
<head>
|
||||
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
|
||||
<meta name="x-apple-disable-message-reformatting" />
|
||||
</head>
|
||||
<body style="background-color: #f5f5f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif">
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="max-width: 560px; margin: 20px auto 0 auto; padding: 20px; background-color: #ffffff; border-radius: 8px; border: 1px solid #ececec">
|
||||
<tbody>
|
||||
<tr style="width: 100%">
|
||||
<td>
|
||||
<h1 style="font-size: 24px; letter-spacing: -0.5px; line-height: 1.3; font-weight: 400; color: #484848; margin-top: 0">Смяна на парола</h1>
|
||||
<p style="font-size: 15px; line-height: 1.4; margin: 0 0 10px; color: #3c4149">Използвайте посочения линк, за да смените вашата парола:</p>
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="padding: 10px 0 0px">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="${link}" style="line-height: 100%; text-decoration: none; display: block; max-width: 100%; background-color: #0052cd; border-radius: 3px; font-weight: 600; color: #fff; font-size: 15px; text-align: center; padding: 11px 23px 11px 23px" target="_blank"
|
||||
><span
|
||||
><!--[if mso]><i style="mso-font-width: 383.33333333333337%; mso-text-raise: 16.5" hidden>   </i><![endif]--></span
|
||||
><span style="max-width: 100%; display: inline-block; line-height: 120%; mso-padding-alt: 0px; mso-text-raise: 8.25px">Смяна на парола</span
|
||||
><span
|
||||
><!--[if mso]><i style="mso-font-width: 383.33333333333337%" hidden>   ​</i><![endif]--></span
|
||||
></a
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<hr style="width: 100%; border: none; border-top: 1px solid #eaeaea; border-color: #dfe1e4; margin: 20px 0 20px" />
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tbody style="width: 100%">
|
||||
<tr style="width: 100%">
|
||||
<td data-id="__react-email-column" style="width: 30px"><img alt="Nhost Logo" height="20" src="https://nhost.io/images/emails/icon.png" style="display: block; outline: none; border: none; text-decoration: none; border-radius: 0; width: 20px; height: 20px" width="20" /></td>
|
||||
<td data-id="__react-email-column" style="margin: 0"><a href="https://nhost.io" style="color: #b4becc; text-decoration: none; font-size: 14px" target="_blank">Powered by Nhost</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1 +0,0 @@
|
||||
Смяна на парола
|
||||
@@ -1,43 +0,0 @@
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<html dir="ltr" lang="en">
|
||||
<head>
|
||||
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
|
||||
<meta name="x-apple-disable-message-reformatting" />
|
||||
</head>
|
||||
<body style="background-color: #f5f5f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif">
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="max-width: 560px; margin: 20px auto 0 auto; padding: 20px; background-color: #ffffff; border-radius: 8px; border: 1px solid #ececec">
|
||||
<tbody>
|
||||
<tr style="width: 100%">
|
||||
<td>
|
||||
<h1 style="font-size: 24px; letter-spacing: -0.5px; line-height: 1.3; font-weight: 400; color: #484848; margin-top: 0">One-time Password</h1>
|
||||
<p style="font-size: 15px; line-height: 1.4; margin: 0 0 10px; color: #3c4149">За да влезете в ${redirectTo}, моля, използвайте следната еднократна парола:</p>
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="padding: 10px 0 0px">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><p style="font-size: 24px; line-height: 32px; margin: 16px 0; color: #0052cd; font-weight: 600">${ticket}</p></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<hr style="width: 100%; border: none; border-top: 1px solid #eaeaea; border-color: #dfe1e4; margin: 20px 0 20px" />
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tbody style="width: 100%">
|
||||
<tr style="width: 100%">
|
||||
<td data-id="__react-email-column" style="width: 30px"><img alt="Nhost Logo" height="20" src="https://nhost.io/images/emails/icon.png" style="display: block; outline: none; border: none; text-decoration: none; border-radius: 0; width: 20px; height: 20px" width="20" /></td>
|
||||
<td data-id="__react-email-column" style="margin: 0"><a href="https://nhost.io" style="color: #b4becc; text-decoration: none; font-size: 14px" target="_blank">Powered by Nhost</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1 +0,0 @@
|
||||
Еднократна парола за ${redirectTo}
|
||||
@@ -1 +0,0 @@
|
||||
Вашият код е ${code}.
|
||||
@@ -1,52 +0,0 @@
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<html dir="ltr" lang="en">
|
||||
<head>
|
||||
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
|
||||
<meta name="x-apple-disable-message-reformatting" />
|
||||
</head>
|
||||
<body style="background-color: #f5f5f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif">
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="max-width: 560px; margin: 20px auto 0 auto; padding: 20px; background-color: #ffffff; border-radius: 8px; border: 1px solid #ececec">
|
||||
<tbody>
|
||||
<tr style="width: 100%">
|
||||
<td>
|
||||
<h1 style="font-size: 24px; letter-spacing: -0.5px; line-height: 1.3; font-weight: 400; color: #484848; margin-top: 0">Магически линк за вход</h1>
|
||||
<p style="font-size: 15px; line-height: 1.4; margin: 0 0 10px; color: #3c4149">Използвайте посочения линк за защитен и бърз вход:</p>
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="padding: 10px 0 0px">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="${link}" style="line-height: 100%; text-decoration: none; display: block; max-width: 100%; background-color: #0052cd; border-radius: 3px; font-weight: 600; color: #fff; font-size: 15px; text-align: center; padding: 11px 23px 11px 23px" target="_blank"
|
||||
><span
|
||||
><!--[if mso]><i style="mso-font-width: 383.33333333333337%; mso-text-raise: 16.5" hidden>   </i><![endif]--></span
|
||||
><span style="max-width: 100%; display: inline-block; line-height: 120%; mso-padding-alt: 0px; mso-text-raise: 8.25px">Вход</span
|
||||
><span
|
||||
><!--[if mso]><i style="mso-font-width: 383.33333333333337%" hidden>   ​</i><![endif]--></span
|
||||
></a
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<hr style="width: 100%; border: none; border-top: 1px solid #eaeaea; border-color: #dfe1e4; margin: 20px 0 20px" />
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tbody style="width: 100%">
|
||||
<tr style="width: 100%">
|
||||
<td data-id="__react-email-column" style="width: 30px"><img alt="Nhost Logo" height="20" src="https://nhost.io/images/emails/icon.png" style="display: block; outline: none; border: none; text-decoration: none; border-radius: 0; width: 20px; height: 20px" width="20" /></td>
|
||||
<td data-id="__react-email-column" style="margin: 0"><a href="https://nhost.io" style="color: #b4becc; text-decoration: none; font-size: 14px" target="_blank">Powered by Nhost</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1 +0,0 @@
|
||||
Магически линк за вход
|
||||
@@ -1,52 +0,0 @@
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<html dir="ltr" lang="en">
|
||||
<head>
|
||||
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
|
||||
<meta name="x-apple-disable-message-reformatting" />
|
||||
</head>
|
||||
<body style="background-color: #f5f5f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif">
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="max-width: 560px; margin: 20px auto 0 auto; padding: 20px; background-color: #ffffff; border-radius: 8px; border: 1px solid #ececec">
|
||||
<tbody>
|
||||
<tr style="width: 100%">
|
||||
<td>
|
||||
<h1 style="font-size: 24px; letter-spacing: -0.5px; line-height: 1.3; font-weight: 400; color: #484848; margin-top: 0">Potvrzení změny emailové adresy</h1>
|
||||
<p style="font-size: 15px; line-height: 1.4; margin: 0 0 10px; color: #3c4149">Použijte tento odkaz k potvrzení změny emailové adresy:</p>
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="padding: 10px 0 0px">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="${link}" style="line-height: 100%; text-decoration: none; display: block; max-width: 100%; background-color: #0052cd; border-radius: 3px; font-weight: 600; color: #fff; font-size: 15px; text-align: center; padding: 11px 23px 11px 23px" target="_blank"
|
||||
><span
|
||||
><!--[if mso]><i style="mso-font-width: 383.33333333333337%; mso-text-raise: 16.5" hidden>   </i><![endif]--></span
|
||||
><span style="max-width: 100%; display: inline-block; line-height: 120%; mso-padding-alt: 0px; mso-text-raise: 8.25px">Změnit email</span
|
||||
><span
|
||||
><!--[if mso]><i style="mso-font-width: 383.33333333333337%" hidden>   ​</i><![endif]--></span
|
||||
></a
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<hr style="width: 100%; border: none; border-top: 1px solid #eaeaea; border-color: #dfe1e4; margin: 20px 0 20px" />
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tbody style="width: 100%">
|
||||
<tr style="width: 100%">
|
||||
<td data-id="__react-email-column" style="width: 30px"><img alt="Nhost Logo" height="20" src="https://nhost.io/images/emails/icon.png" style="display: block; outline: none; border: none; text-decoration: none; border-radius: 0; width: 20px; height: 20px" width="20" /></td>
|
||||
<td data-id="__react-email-column" style="margin: 0"><a href="https://nhost.io" style="color: #b4becc; text-decoration: none; font-size: 14px" target="_blank">Powered by Nhost</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1 +0,0 @@
|
||||
Změna vaší emailové adresy
|
||||
@@ -1,52 +0,0 @@
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<html dir="ltr" lang="en">
|
||||
<head>
|
||||
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
|
||||
<meta name="x-apple-disable-message-reformatting" />
|
||||
</head>
|
||||
<body style="background-color: #f5f5f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif">
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="max-width: 560px; margin: 20px auto 0 auto; padding: 20px; background-color: #ffffff; border-radius: 8px; border: 1px solid #ececec">
|
||||
<tbody>
|
||||
<tr style="width: 100%">
|
||||
<td>
|
||||
<h1 style="font-size: 24px; letter-spacing: -0.5px; line-height: 1.3; font-weight: 400; color: #484848; margin-top: 0">Ověření emailové adresy</h1>
|
||||
<p style="font-size: 15px; line-height: 1.4; margin: 0 0 10px; color: #3c4149">Použijte tento odkaz k ověření vaší emailové adresy:</p>
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="padding: 10px 0 0px">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="${link}" style="line-height: 100%; text-decoration: none; display: block; max-width: 100%; background-color: #0052cd; border-radius: 3px; font-weight: 600; color: #fff; font-size: 15px; text-align: center; padding: 11px 23px 11px 23px" target="_blank"
|
||||
><span
|
||||
><!--[if mso]><i style="mso-font-width: 383.33333333333337%; mso-text-raise: 16.5" hidden>   </i><![endif]--></span
|
||||
><span style="max-width: 100%; display: inline-block; line-height: 120%; mso-padding-alt: 0px; mso-text-raise: 8.25px">Ověřit emailovou adresu</span
|
||||
><span
|
||||
><!--[if mso]><i style="mso-font-width: 383.33333333333337%" hidden>   ​</i><![endif]--></span
|
||||
></a
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<hr style="width: 100%; border: none; border-top: 1px solid #eaeaea; border-color: #dfe1e4; margin: 20px 0 20px" />
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tbody style="width: 100%">
|
||||
<tr style="width: 100%">
|
||||
<td data-id="__react-email-column" style="width: 30px"><img alt="Nhost Logo" height="20" src="https://nhost.io/images/emails/icon.png" style="display: block; outline: none; border: none; text-decoration: none; border-radius: 0; width: 20px; height: 20px" width="20" /></td>
|
||||
<td data-id="__react-email-column" style="margin: 0"><a href="https://nhost.io" style="color: #b4becc; text-decoration: none; font-size: 14px" target="_blank">Powered by Nhost</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1 +0,0 @@
|
||||
Ověření vaší emailové adresy
|
||||
@@ -1,52 +0,0 @@
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<html dir="ltr" lang="en">
|
||||
<head>
|
||||
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
|
||||
<meta name="x-apple-disable-message-reformatting" />
|
||||
</head>
|
||||
<body style="background-color: #f5f5f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif">
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="max-width: 560px; margin: 20px auto 0 auto; padding: 20px; background-color: #ffffff; border-radius: 8px; border: 1px solid #ececec">
|
||||
<tbody>
|
||||
<tr style="width: 100%">
|
||||
<td>
|
||||
<h1 style="font-size: 24px; letter-spacing: -0.5px; line-height: 1.3; font-weight: 400; color: #484848; margin-top: 0">Obnova hesla</h1>
|
||||
<p style="font-size: 15px; line-height: 1.4; margin: 0 0 10px; color: #3c4149">Použijte tento odkaz k obnovení vašeho hesla:</p>
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="padding: 10px 0 0px">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="${link}" style="line-height: 100%; text-decoration: none; display: block; max-width: 100%; background-color: #0052cd; border-radius: 3px; font-weight: 600; color: #fff; font-size: 15px; text-align: center; padding: 11px 23px 11px 23px" target="_blank"
|
||||
><span
|
||||
><!--[if mso]><i style="mso-font-width: 383.33333333333337%; mso-text-raise: 16.5" hidden>   </i><![endif]--></span
|
||||
><span style="max-width: 100%; display: inline-block; line-height: 120%; mso-padding-alt: 0px; mso-text-raise: 8.25px">Obnova hesla</span
|
||||
><span
|
||||
><!--[if mso]><i style="mso-font-width: 383.33333333333337%" hidden>   ​</i><![endif]--></span
|
||||
></a
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<hr style="width: 100%; border: none; border-top: 1px solid #eaeaea; border-color: #dfe1e4; margin: 20px 0 20px" />
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tbody style="width: 100%">
|
||||
<tr style="width: 100%">
|
||||
<td data-id="__react-email-column" style="width: 30px"><img alt="Nhost Logo" height="20" src="https://nhost.io/images/emails/icon.png" style="display: block; outline: none; border: none; text-decoration: none; border-radius: 0; width: 20px; height: 20px" width="20" /></td>
|
||||
<td data-id="__react-email-column" style="margin: 0"><a href="https://nhost.io" style="color: #b4becc; text-decoration: none; font-size: 14px" target="_blank">Powered by Nhost</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1 +0,0 @@
|
||||
Obnova hesla
|
||||
@@ -1,43 +0,0 @@
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<html dir="ltr" lang="en">
|
||||
<head>
|
||||
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
|
||||
<meta name="x-apple-disable-message-reformatting" />
|
||||
</head>
|
||||
<body style="background-color: #f5f5f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif">
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="max-width: 560px; margin: 20px auto 0 auto; padding: 20px; background-color: #ffffff; border-radius: 8px; border: 1px solid #ececec">
|
||||
<tbody>
|
||||
<tr style="width: 100%">
|
||||
<td>
|
||||
<h1 style="font-size: 24px; letter-spacing: -0.5px; line-height: 1.3; font-weight: 400; color: #484848; margin-top: 0">One-time Password</h1>
|
||||
<p style="font-size: 15px; line-height: 1.4; margin: 0 0 10px; color: #3c4149">Pro přihlášení do ${redirectTo}, prosím, použijte následující jednorázové heslo:</p>
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="padding: 10px 0 0px">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><p style="font-size: 24px; line-height: 32px; margin: 16px 0; color: #0052cd; font-weight: 600">${ticket}</p></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<hr style="width: 100%; border: none; border-top: 1px solid #eaeaea; border-color: #dfe1e4; margin: 20px 0 20px" />
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tbody style="width: 100%">
|
||||
<tr style="width: 100%">
|
||||
<td data-id="__react-email-column" style="width: 30px"><img alt="Nhost Logo" height="20" src="https://nhost.io/images/emails/icon.png" style="display: block; outline: none; border: none; text-decoration: none; border-radius: 0; width: 20px; height: 20px" width="20" /></td>
|
||||
<td data-id="__react-email-column" style="margin: 0"><a href="https://nhost.io" style="color: #b4becc; text-decoration: none; font-size: 14px" target="_blank">Powered by Nhost</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1 +0,0 @@
|
||||
Jednorázové heslo pro ${redirectTo}
|
||||
@@ -1 +0,0 @@
|
||||
Váš kód je ${code}.
|
||||
@@ -1,52 +0,0 @@
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<html dir="ltr" lang="en">
|
||||
<head>
|
||||
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
|
||||
<meta name="x-apple-disable-message-reformatting" />
|
||||
</head>
|
||||
<body style="background-color: #f5f5f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif">
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="max-width: 560px; margin: 20px auto 0 auto; padding: 20px; background-color: #ffffff; border-radius: 8px; border: 1px solid #ececec">
|
||||
<tbody>
|
||||
<tr style="width: 100%">
|
||||
<td>
|
||||
<h1 style="font-size: 24px; letter-spacing: -0.5px; line-height: 1.3; font-weight: 400; color: #484848; margin-top: 0">Magický odkaz</h1>
|
||||
<p style="font-size: 15px; line-height: 1.4; margin: 0 0 10px; color: #3c4149">Použijte tento odkaz k bezpečnému přihlášení:</p>
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="padding: 10px 0 0px">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="${link}" style="line-height: 100%; text-decoration: none; display: block; max-width: 100%; background-color: #0052cd; border-radius: 3px; font-weight: 600; color: #fff; font-size: 15px; text-align: center; padding: 11px 23px 11px 23px" target="_blank"
|
||||
><span
|
||||
><!--[if mso]><i style="mso-font-width: 383.33333333333337%; mso-text-raise: 16.5" hidden>   </i><![endif]--></span
|
||||
><span style="max-width: 100%; display: inline-block; line-height: 120%; mso-padding-alt: 0px; mso-text-raise: 8.25px">Přihlášení</span
|
||||
><span
|
||||
><!--[if mso]><i style="mso-font-width: 383.33333333333337%" hidden>   ​</i><![endif]--></span
|
||||
></a
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<hr style="width: 100%; border: none; border-top: 1px solid #eaeaea; border-color: #dfe1e4; margin: 20px 0 20px" />
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tbody style="width: 100%">
|
||||
<tr style="width: 100%">
|
||||
<td data-id="__react-email-column" style="width: 30px"><img alt="Nhost Logo" height="20" src="https://nhost.io/images/emails/icon.png" style="display: block; outline: none; border: none; text-decoration: none; border-radius: 0; width: 20px; height: 20px" width="20" /></td>
|
||||
<td data-id="__react-email-column" style="margin: 0"><a href="https://nhost.io" style="color: #b4becc; text-decoration: none; font-size: 14px" target="_blank">Powered by Nhost</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1 +0,0 @@
|
||||
Bezpečný odkaz k přihlášení
|
||||
@@ -1,52 +0,0 @@
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<html dir="ltr" lang="en">
|
||||
<head>
|
||||
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
|
||||
<meta name="x-apple-disable-message-reformatting" />
|
||||
</head>
|
||||
<body style="background-color: #f5f5f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif">
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="max-width: 560px; margin: 20px auto 0 auto; padding: 20px; background-color: #ffffff; border-radius: 8px; border: 1px solid #ececec">
|
||||
<tbody>
|
||||
<tr style="width: 100%">
|
||||
<td>
|
||||
<h1 style="font-size: 24px; letter-spacing: -0.5px; line-height: 1.3; font-weight: 400; color: #484848; margin-top: 0">Confirm Email Change</h1>
|
||||
<p style="font-size: 15px; line-height: 1.4; margin: 0 0 10px; color: #3c4149">Use this link to confirm changing email:</p>
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="padding: 10px 0 0px">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="${link}" style="line-height: 100%; text-decoration: none; display: block; max-width: 100%; background-color: #0052cd; border-radius: 3px; font-weight: 600; color: #fff; font-size: 15px; text-align: center; padding: 11px 23px 11px 23px" target="_blank"
|
||||
><span
|
||||
><!--[if mso]><i style="mso-font-width: 383.33333333333337%; mso-text-raise: 16.5" hidden>   </i><![endif]--></span
|
||||
><span style="max-width: 100%; display: inline-block; line-height: 120%; mso-padding-alt: 0px; mso-text-raise: 8.25px">Change Email</span
|
||||
><span
|
||||
><!--[if mso]><i style="mso-font-width: 383.33333333333337%" hidden>   ​</i><![endif]--></span
|
||||
></a
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<hr style="width: 100%; border: none; border-top: 1px solid #eaeaea; border-color: #dfe1e4; margin: 20px 0 20px" />
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tbody style="width: 100%">
|
||||
<tr style="width: 100%">
|
||||
<td data-id="__react-email-column" style="width: 30px"><img alt="Nhost Logo" height="20" src="https://nhost.io/images/emails/icon.png" style="display: block; outline: none; border: none; text-decoration: none; border-radius: 0; width: 20px; height: 20px" width="20" /></td>
|
||||
<td data-id="__react-email-column" style="margin: 0"><a href="https://nhost.io" style="color: #b4becc; text-decoration: none; font-size: 14px" target="_blank">Powered by Nhost</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1 +0,0 @@
|
||||
Change your email address
|
||||
@@ -1,52 +0,0 @@
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<html dir="ltr" lang="en">
|
||||
<head>
|
||||
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
|
||||
<meta name="x-apple-disable-message-reformatting" />
|
||||
</head>
|
||||
<body style="background-color: #f5f5f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif">
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="max-width: 560px; margin: 20px auto 0 auto; padding: 20px; background-color: #ffffff; border-radius: 8px; border: 1px solid #ececec">
|
||||
<tbody>
|
||||
<tr style="width: 100%">
|
||||
<td>
|
||||
<h1 style="font-size: 24px; letter-spacing: -0.5px; line-height: 1.3; font-weight: 400; color: #484848; margin-top: 0">Verify Email</h1>
|
||||
<p style="font-size: 15px; line-height: 1.4; margin: 0 0 10px; color: #3c4149">Use this link to verify your email:</p>
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="padding: 10px 0 0px">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="${link}" style="line-height: 100%; text-decoration: none; display: block; max-width: 100%; background-color: #0052cd; border-radius: 3px; font-weight: 600; color: #fff; font-size: 15px; text-align: center; padding: 11px 23px 11px 23px" target="_blank"
|
||||
><span
|
||||
><!--[if mso]><i style="mso-font-width: 383.33333333333337%; mso-text-raise: 16.5" hidden>   </i><![endif]--></span
|
||||
><span style="max-width: 100%; display: inline-block; line-height: 120%; mso-padding-alt: 0px; mso-text-raise: 8.25px">Verify Email</span
|
||||
><span
|
||||
><!--[if mso]><i style="mso-font-width: 383.33333333333337%" hidden>   ​</i><![endif]--></span
|
||||
></a
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<hr style="width: 100%; border: none; border-top: 1px solid #eaeaea; border-color: #dfe1e4; margin: 20px 0 20px" />
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tbody style="width: 100%">
|
||||
<tr style="width: 100%">
|
||||
<td data-id="__react-email-column" style="width: 30px"><img alt="Nhost Logo" height="20" src="https://nhost.io/images/emails/icon.png" style="display: block; outline: none; border: none; text-decoration: none; border-radius: 0; width: 20px; height: 20px" width="20" /></td>
|
||||
<td data-id="__react-email-column" style="margin: 0"><a href="https://nhost.io" style="color: #b4becc; text-decoration: none; font-size: 14px" target="_blank">Powered by Nhost</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1 +0,0 @@
|
||||
Verify your email
|
||||
@@ -1,52 +0,0 @@
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<html dir="ltr" lang="en">
|
||||
<head>
|
||||
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
|
||||
<meta name="x-apple-disable-message-reformatting" />
|
||||
</head>
|
||||
<body style="background-color: #f5f5f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif">
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="max-width: 560px; margin: 20px auto 0 auto; padding: 20px; background-color: #ffffff; border-radius: 8px; border: 1px solid #ececec">
|
||||
<tbody>
|
||||
<tr style="width: 100%">
|
||||
<td>
|
||||
<h1 style="font-size: 24px; letter-spacing: -0.5px; line-height: 1.3; font-weight: 400; color: #484848; margin-top: 0">Reset Password</h1>
|
||||
<p style="font-size: 15px; line-height: 1.4; margin: 0 0 10px; color: #3c4149">Use this link to reset your password:</p>
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="padding: 10px 0 0px">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="${link}" style="line-height: 100%; text-decoration: none; display: block; max-width: 100%; background-color: #0052cd; border-radius: 3px; font-weight: 600; color: #fff; font-size: 15px; text-align: center; padding: 11px 23px 11px 23px" target="_blank"
|
||||
><span
|
||||
><!--[if mso]><i style="mso-font-width: 383.33333333333337%; mso-text-raise: 16.5" hidden>   </i><![endif]--></span
|
||||
><span style="max-width: 100%; display: inline-block; line-height: 120%; mso-padding-alt: 0px; mso-text-raise: 8.25px">Reset Password</span
|
||||
><span
|
||||
><!--[if mso]><i style="mso-font-width: 383.33333333333337%" hidden>   ​</i><![endif]--></span
|
||||
></a
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<hr style="width: 100%; border: none; border-top: 1px solid #eaeaea; border-color: #dfe1e4; margin: 20px 0 20px" />
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tbody style="width: 100%">
|
||||
<tr style="width: 100%">
|
||||
<td data-id="__react-email-column" style="width: 30px"><img alt="Nhost Logo" height="20" src="https://nhost.io/images/emails/icon.png" style="display: block; outline: none; border: none; text-decoration: none; border-radius: 0; width: 20px; height: 20px" width="20" /></td>
|
||||
<td data-id="__react-email-column" style="margin: 0"><a href="https://nhost.io" style="color: #b4becc; text-decoration: none; font-size: 14px" target="_blank">Powered by Nhost</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1 +0,0 @@
|
||||
Reset your password
|
||||
@@ -1,43 +0,0 @@
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<html dir="ltr" lang="en">
|
||||
<head>
|
||||
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
|
||||
<meta name="x-apple-disable-message-reformatting" />
|
||||
</head>
|
||||
<body style="background-color: #f5f5f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif">
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="max-width: 560px; margin: 20px auto 0 auto; padding: 20px; background-color: #ffffff; border-radius: 8px; border: 1px solid #ececec">
|
||||
<tbody>
|
||||
<tr style="width: 100%">
|
||||
<td>
|
||||
<h1 style="font-size: 24px; letter-spacing: -0.5px; line-height: 1.3; font-weight: 400; color: #484848; margin-top: 0">One-time Password</h1>
|
||||
<p style="font-size: 15px; line-height: 1.4; margin: 0 0 10px; color: #3c4149">To sign in to ${redirectTo}, please, use the following one-time password:</p>
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="padding: 10px 0 0px">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><p style="font-size: 24px; line-height: 32px; margin: 16px 0; color: #0052cd; font-weight: 600">${ticket}</p></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<hr style="width: 100%; border: none; border-top: 1px solid #eaeaea; border-color: #dfe1e4; margin: 20px 0 20px" />
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tbody style="width: 100%">
|
||||
<tr style="width: 100%">
|
||||
<td data-id="__react-email-column" style="width: 30px"><img alt="Nhost Logo" height="20" src="https://nhost.io/images/emails/icon.png" style="display: block; outline: none; border: none; text-decoration: none; border-radius: 0; width: 20px; height: 20px" width="20" /></td>
|
||||
<td data-id="__react-email-column" style="margin: 0"><a href="https://nhost.io" style="color: #b4becc; text-decoration: none; font-size: 14px" target="_blank">Powered by Nhost</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1 +0,0 @@
|
||||
One-time password for ${redirectTo}
|
||||
@@ -1 +0,0 @@
|
||||
Your code is ${code}.
|
||||
@@ -1,52 +0,0 @@
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<html dir="ltr" lang="en">
|
||||
<head>
|
||||
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
|
||||
<meta name="x-apple-disable-message-reformatting" />
|
||||
</head>
|
||||
<body style="background-color: #f5f5f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif">
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="max-width: 560px; margin: 20px auto 0 auto; padding: 20px; background-color: #ffffff; border-radius: 8px; border: 1px solid #ececec">
|
||||
<tbody>
|
||||
<tr style="width: 100%">
|
||||
<td>
|
||||
<h1 style="font-size: 24px; letter-spacing: -0.5px; line-height: 1.3; font-weight: 400; color: #484848; margin-top: 0">Magic Link</h1>
|
||||
<p style="font-size: 15px; line-height: 1.4; margin: 0 0 10px; color: #3c4149">Use this link to securely sign in:</p>
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="padding: 10px 0 0px">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="${link}" style="line-height: 100%; text-decoration: none; display: block; max-width: 100%; background-color: #0052cd; border-radius: 3px; font-weight: 600; color: #fff; font-size: 15px; text-align: center; padding: 11px 23px 11px 23px" target="_blank"
|
||||
><span
|
||||
><!--[if mso]><i style="mso-font-width: 383.33333333333337%; mso-text-raise: 16.5" hidden>   </i><![endif]--></span
|
||||
><span style="max-width: 100%; display: inline-block; line-height: 120%; mso-padding-alt: 0px; mso-text-raise: 8.25px">Magic Link</span
|
||||
><span
|
||||
><!--[if mso]><i style="mso-font-width: 383.33333333333337%" hidden>   ​</i><![endif]--></span
|
||||
></a
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<hr style="width: 100%; border: none; border-top: 1px solid #eaeaea; border-color: #dfe1e4; margin: 20px 0 20px" />
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tbody style="width: 100%">
|
||||
<tr style="width: 100%">
|
||||
<td data-id="__react-email-column" style="width: 30px"><img alt="Nhost Logo" height="20" src="https://nhost.io/images/emails/icon.png" style="display: block; outline: none; border: none; text-decoration: none; border-radius: 0; width: 20px; height: 20px" width="20" /></td>
|
||||
<td data-id="__react-email-column" style="margin: 0"><a href="https://nhost.io" style="color: #b4becc; text-decoration: none; font-size: 14px" target="_blank">Powered by Nhost</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1 +0,0 @@
|
||||
Secure sign-in link
|
||||
@@ -1,52 +0,0 @@
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<html dir="ltr" lang="en">
|
||||
<head>
|
||||
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
|
||||
<meta name="x-apple-disable-message-reformatting" />
|
||||
</head>
|
||||
<body style="background-color: #f5f5f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif">
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="max-width: 560px; margin: 20px auto 0 auto; padding: 20px; background-color: #ffffff; border-radius: 8px; border: 1px solid #ececec">
|
||||
<tbody>
|
||||
<tr style="width: 100%">
|
||||
<td>
|
||||
<h1 style="font-size: 24px; letter-spacing: -0.5px; line-height: 1.3; font-weight: 400; color: #484848; margin-top: 0">Confirmar cambio de correo electrónico</h1>
|
||||
<p style="font-size: 15px; line-height: 1.4; margin: 0 0 10px; color: #3c4149">Utiliza el siguiente enlace para confirmar el cambio de correo:</p>
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="padding: 10px 0 0px">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="${link}" style="line-height: 100%; text-decoration: none; display: block; max-width: 100%; background-color: #0052cd; border-radius: 3px; font-weight: 600; color: #fff; font-size: 15px; text-align: center; padding: 11px 23px 11px 23px" target="_blank"
|
||||
><span
|
||||
><!--[if mso]><i style="mso-font-width: 383.33333333333337%; mso-text-raise: 16.5" hidden>   </i><![endif]--></span
|
||||
><span style="max-width: 100%; display: inline-block; line-height: 120%; mso-padding-alt: 0px; mso-text-raise: 8.25px">Cambiar correo electrónico</span
|
||||
><span
|
||||
><!--[if mso]><i style="mso-font-width: 383.33333333333337%" hidden>   ​</i><![endif]--></span
|
||||
></a
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<hr style="width: 100%; border: none; border-top: 1px solid #eaeaea; border-color: #dfe1e4; margin: 20px 0 20px" />
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tbody style="width: 100%">
|
||||
<tr style="width: 100%">
|
||||
<td data-id="__react-email-column" style="width: 30px"><img alt="Nhost Logo" height="20" src="https://nhost.io/images/emails/icon.png" style="display: block; outline: none; border: none; text-decoration: none; border-radius: 0; width: 20px; height: 20px" width="20" /></td>
|
||||
<td data-id="__react-email-column" style="margin: 0"><a href="https://nhost.io" style="color: #b4becc; text-decoration: none; font-size: 14px" target="_blank">Powered by Nhost</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1 +0,0 @@
|
||||
Cambiar dirección de correo electrónico
|
||||
@@ -1,52 +0,0 @@
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<html dir="ltr" lang="en">
|
||||
<head>
|
||||
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
|
||||
<meta name="x-apple-disable-message-reformatting" />
|
||||
</head>
|
||||
<body style="background-color: #f5f5f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif">
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="max-width: 560px; margin: 20px auto 0 auto; padding: 20px; background-color: #ffffff; border-radius: 8px; border: 1px solid #ececec">
|
||||
<tbody>
|
||||
<tr style="width: 100%">
|
||||
<td>
|
||||
<h1 style="font-size: 24px; letter-spacing: -0.5px; line-height: 1.3; font-weight: 400; color: #484848; margin-top: 0">Verificar correo electrónico</h1>
|
||||
<p style="font-size: 15px; line-height: 1.4; margin: 0 0 10px; color: #3c4149">Utilza el siguiente enlace para verificar tu correo:</p>
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="padding: 10px 0 0px">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="${link}" style="line-height: 100%; text-decoration: none; display: block; max-width: 100%; background-color: #0052cd; border-radius: 3px; font-weight: 600; color: #fff; font-size: 15px; text-align: center; padding: 11px 23px 11px 23px" target="_blank"
|
||||
><span
|
||||
><!--[if mso]><i style="mso-font-width: 383.33333333333337%; mso-text-raise: 16.5" hidden>   </i><![endif]--></span
|
||||
><span style="max-width: 100%; display: inline-block; line-height: 120%; mso-padding-alt: 0px; mso-text-raise: 8.25px">Verificar correo electrónico</span
|
||||
><span
|
||||
><!--[if mso]><i style="mso-font-width: 383.33333333333337%" hidden>   ​</i><![endif]--></span
|
||||
></a
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<hr style="width: 100%; border: none; border-top: 1px solid #eaeaea; border-color: #dfe1e4; margin: 20px 0 20px" />
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tbody style="width: 100%">
|
||||
<tr style="width: 100%">
|
||||
<td data-id="__react-email-column" style="width: 30px"><img alt="Nhost Logo" height="20" src="https://nhost.io/images/emails/icon.png" style="display: block; outline: none; border: none; text-decoration: none; border-radius: 0; width: 20px; height: 20px" width="20" /></td>
|
||||
<td data-id="__react-email-column" style="margin: 0"><a href="https://nhost.io" style="color: #b4becc; text-decoration: none; font-size: 14px" target="_blank">Powered by Nhost</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1 +0,0 @@
|
||||
Verifica tu correo electrónico
|
||||
@@ -1,52 +0,0 @@
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<html dir="ltr" lang="en">
|
||||
<head>
|
||||
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
|
||||
<meta name="x-apple-disable-message-reformatting" />
|
||||
</head>
|
||||
<body style="background-color: #f5f5f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif">
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="max-width: 560px; margin: 20px auto 0 auto; padding: 20px; background-color: #ffffff; border-radius: 8px; border: 1px solid #ececec">
|
||||
<tbody>
|
||||
<tr style="width: 100%">
|
||||
<td>
|
||||
<h1 style="font-size: 24px; letter-spacing: -0.5px; line-height: 1.3; font-weight: 400; color: #484848; margin-top: 0">Recuperar contraseña</h1>
|
||||
<p style="font-size: 15px; line-height: 1.4; margin: 0 0 10px; color: #3c4149">Utiliza el siguiente enlace para recuperar tu contraseña:</p>
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="padding: 10px 0 0px">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="${link}" style="line-height: 100%; text-decoration: none; display: block; max-width: 100%; background-color: #0052cd; border-radius: 3px; font-weight: 600; color: #fff; font-size: 15px; text-align: center; padding: 11px 23px 11px 23px" target="_blank"
|
||||
><span
|
||||
><!--[if mso]><i style="mso-font-width: 383.33333333333337%; mso-text-raise: 16.5" hidden>   </i><![endif]--></span
|
||||
><span style="max-width: 100%; display: inline-block; line-height: 120%; mso-padding-alt: 0px; mso-text-raise: 8.25px">Recuperar contraseña</span
|
||||
><span
|
||||
><!--[if mso]><i style="mso-font-width: 383.33333333333337%" hidden>   ​</i><![endif]--></span
|
||||
></a
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<hr style="width: 100%; border: none; border-top: 1px solid #eaeaea; border-color: #dfe1e4; margin: 20px 0 20px" />
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tbody style="width: 100%">
|
||||
<tr style="width: 100%">
|
||||
<td data-id="__react-email-column" style="width: 30px"><img alt="Nhost Logo" height="20" src="https://nhost.io/images/emails/icon.png" style="display: block; outline: none; border: none; text-decoration: none; border-radius: 0; width: 20px; height: 20px" width="20" /></td>
|
||||
<td data-id="__react-email-column" style="margin: 0"><a href="https://nhost.io" style="color: #b4becc; text-decoration: none; font-size: 14px" target="_blank">Powered by Nhost</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1 +0,0 @@
|
||||
Recuperar contraseña
|
||||
@@ -1,43 +0,0 @@
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<html dir="ltr" lang="en">
|
||||
<head>
|
||||
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
|
||||
<meta name="x-apple-disable-message-reformatting" />
|
||||
</head>
|
||||
<body style="background-color: #f5f5f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif">
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="max-width: 560px; margin: 20px auto 0 auto; padding: 20px; background-color: #ffffff; border-radius: 8px; border: 1px solid #ececec">
|
||||
<tbody>
|
||||
<tr style="width: 100%">
|
||||
<td>
|
||||
<h1 style="font-size: 24px; letter-spacing: -0.5px; line-height: 1.3; font-weight: 400; color: #484848; margin-top: 0">One-time Password</h1>
|
||||
<p style="font-size: 15px; line-height: 1.4; margin: 0 0 10px; color: #3c4149">Para iniciar sesión en ${redirectTo}, por favor, utilice la siguiente contraseña de un solo uso:</p>
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="padding: 10px 0 0px">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><p style="font-size: 24px; line-height: 32px; margin: 16px 0; color: #0052cd; font-weight: 600">${ticket}</p></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<hr style="width: 100%; border: none; border-top: 1px solid #eaeaea; border-color: #dfe1e4; margin: 20px 0 20px" />
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tbody style="width: 100%">
|
||||
<tr style="width: 100%">
|
||||
<td data-id="__react-email-column" style="width: 30px"><img alt="Nhost Logo" height="20" src="https://nhost.io/images/emails/icon.png" style="display: block; outline: none; border: none; text-decoration: none; border-radius: 0; width: 20px; height: 20px" width="20" /></td>
|
||||
<td data-id="__react-email-column" style="margin: 0"><a href="https://nhost.io" style="color: #b4becc; text-decoration: none; font-size: 14px" target="_blank">Powered by Nhost</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1 +0,0 @@
|
||||
Contraseña de un solo uso para ${redirectTo}
|
||||
@@ -1 +0,0 @@
|
||||
Tu código es ${code}.
|
||||
@@ -1,52 +0,0 @@
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<html dir="ltr" lang="en">
|
||||
<head>
|
||||
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
|
||||
<meta name="x-apple-disable-message-reformatting" />
|
||||
</head>
|
||||
<body style="background-color: #f5f5f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif">
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="max-width: 560px; margin: 20px auto 0 auto; padding: 20px; background-color: #ffffff; border-radius: 8px; border: 1px solid #ececec">
|
||||
<tbody>
|
||||
<tr style="width: 100%">
|
||||
<td>
|
||||
<h1 style="font-size: 24px; letter-spacing: -0.5px; line-height: 1.3; font-weight: 400; color: #484848; margin-top: 0">Enlace mágico</h1>
|
||||
<p style="font-size: 15px; line-height: 1.4; margin: 0 0 10px; color: #3c4149">Utiliza este enlace para iniciar sesión de forma segura:</p>
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="padding: 10px 0 0px">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="${link}" style="line-height: 100%; text-decoration: none; display: block; max-width: 100%; background-color: #0052cd; border-radius: 3px; font-weight: 600; color: #fff; font-size: 15px; text-align: center; padding: 11px 23px 11px 23px" target="_blank"
|
||||
><span
|
||||
><!--[if mso]><i style="mso-font-width: 383.33333333333337%; mso-text-raise: 16.5" hidden>   </i><![endif]--></span
|
||||
><span style="max-width: 100%; display: inline-block; line-height: 120%; mso-padding-alt: 0px; mso-text-raise: 8.25px">Iniciar sesión</span
|
||||
><span
|
||||
><!--[if mso]><i style="mso-font-width: 383.33333333333337%" hidden>   ​</i><![endif]--></span
|
||||
></a
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<hr style="width: 100%; border: none; border-top: 1px solid #eaeaea; border-color: #dfe1e4; margin: 20px 0 20px" />
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tbody style="width: 100%">
|
||||
<tr style="width: 100%">
|
||||
<td data-id="__react-email-column" style="width: 30px"><img alt="Nhost Logo" height="20" src="https://nhost.io/images/emails/icon.png" style="display: block; outline: none; border: none; text-decoration: none; border-radius: 0; width: 20px; height: 20px" width="20" /></td>
|
||||
<td data-id="__react-email-column" style="margin: 0"><a href="https://nhost.io" style="color: #b4becc; text-decoration: none; font-size: 14px" target="_blank">Powered by Nhost</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1 +0,0 @@
|
||||
Enlace de acceso seguro
|
||||
@@ -1,52 +0,0 @@
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<html dir="ltr" lang="en">
|
||||
<head>
|
||||
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
|
||||
<meta name="x-apple-disable-message-reformatting" />
|
||||
</head>
|
||||
<body style="background-color: #f5f5f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif">
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="max-width: 560px; margin: 20px auto 0 auto; padding: 20px; background-color: #ffffff; border-radius: 8px; border: 1px solid #ececec">
|
||||
<tbody>
|
||||
<tr style="width: 100%">
|
||||
<td>
|
||||
<h1 style="font-size: 24px; letter-spacing: -0.5px; line-height: 1.3; font-weight: 400; color: #484848; margin-top: 0">Confirmer changement de courriel</h1>
|
||||
<p style="font-size: 15px; line-height: 1.4; margin: 0 0 10px; color: #3c4149">Utilisez ce lien pour confirmer le changement de courriel:</p>
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="padding: 10px 0 0px">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="${link}" style="line-height: 100%; text-decoration: none; display: block; max-width: 100%; background-color: #0052cd; border-radius: 3px; font-weight: 600; color: #fff; font-size: 15px; text-align: center; padding: 11px 23px 11px 23px" target="_blank"
|
||||
><span
|
||||
><!--[if mso]><i style="mso-font-width: 383.33333333333337%; mso-text-raise: 16.5" hidden>   </i><![endif]--></span
|
||||
><span style="max-width: 100%; display: inline-block; line-height: 120%; mso-padding-alt: 0px; mso-text-raise: 8.25px">Changer courriel</span
|
||||
><span
|
||||
><!--[if mso]><i style="mso-font-width: 383.33333333333337%" hidden>   ​</i><![endif]--></span
|
||||
></a
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<hr style="width: 100%; border: none; border-top: 1px solid #eaeaea; border-color: #dfe1e4; margin: 20px 0 20px" />
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tbody style="width: 100%">
|
||||
<tr style="width: 100%">
|
||||
<td data-id="__react-email-column" style="width: 30px"><img alt="Nhost Logo" height="20" src="https://nhost.io/images/emails/icon.png" style="display: block; outline: none; border: none; text-decoration: none; border-radius: 0; width: 20px; height: 20px" width="20" /></td>
|
||||
<td data-id="__react-email-column" style="margin: 0"><a href="https://nhost.io" style="color: #b4becc; text-decoration: none; font-size: 14px" target="_blank">Powered by Nhost</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1 +0,0 @@
|
||||
Changez votre adresse courriel
|
||||
@@ -1,52 +0,0 @@
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<html dir="ltr" lang="en">
|
||||
<head>
|
||||
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
|
||||
<meta name="x-apple-disable-message-reformatting" />
|
||||
</head>
|
||||
<body style="background-color: #f5f5f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif">
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="max-width: 560px; margin: 20px auto 0 auto; padding: 20px; background-color: #ffffff; border-radius: 8px; border: 1px solid #ececec">
|
||||
<tbody>
|
||||
<tr style="width: 100%">
|
||||
<td>
|
||||
<h1 style="font-size: 24px; letter-spacing: -0.5px; line-height: 1.3; font-weight: 400; color: #484848; margin-top: 0">Vérifiez votre courriel</h1>
|
||||
<p style="font-size: 15px; line-height: 1.4; margin: 0 0 10px; color: #3c4149">Utilisez ce lien pour vérifier votre courriel:</p>
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="padding: 10px 0 0px">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="${link}" style="line-height: 100%; text-decoration: none; display: block; max-width: 100%; background-color: #0052cd; border-radius: 3px; font-weight: 600; color: #fff; font-size: 15px; text-align: center; padding: 11px 23px 11px 23px" target="_blank"
|
||||
><span
|
||||
><!--[if mso]><i style="mso-font-width: 383.33333333333337%; mso-text-raise: 16.5" hidden>   </i><![endif]--></span
|
||||
><span style="max-width: 100%; display: inline-block; line-height: 120%; mso-padding-alt: 0px; mso-text-raise: 8.25px">Vérifier courriel</span
|
||||
><span
|
||||
><!--[if mso]><i style="mso-font-width: 383.33333333333337%" hidden>   ​</i><![endif]--></span
|
||||
></a
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<hr style="width: 100%; border: none; border-top: 1px solid #eaeaea; border-color: #dfe1e4; margin: 20px 0 20px" />
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tbody style="width: 100%">
|
||||
<tr style="width: 100%">
|
||||
<td data-id="__react-email-column" style="width: 30px"><img alt="Nhost Logo" height="20" src="https://nhost.io/images/emails/icon.png" style="display: block; outline: none; border: none; text-decoration: none; border-radius: 0; width: 20px; height: 20px" width="20" /></td>
|
||||
<td data-id="__react-email-column" style="margin: 0"><a href="https://nhost.io" style="color: #b4becc; text-decoration: none; font-size: 14px" target="_blank">Powered by Nhost</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1 +0,0 @@
|
||||
Vérifier votre courriel
|
||||
@@ -1,52 +0,0 @@
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<html dir="ltr" lang="en">
|
||||
<head>
|
||||
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
|
||||
<meta name="x-apple-disable-message-reformatting" />
|
||||
</head>
|
||||
<body style="background-color: #f5f5f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif">
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="max-width: 560px; margin: 20px auto 0 auto; padding: 20px; background-color: #ffffff; border-radius: 8px; border: 1px solid #ececec">
|
||||
<tbody>
|
||||
<tr style="width: 100%">
|
||||
<td>
|
||||
<h1 style="font-size: 24px; letter-spacing: -0.5px; line-height: 1.3; font-weight: 400; color: #484848; margin-top: 0">Réinitialiser votre mot de passe</h1>
|
||||
<p style="font-size: 15px; line-height: 1.4; margin: 0 0 10px; color: #3c4149">Utilisez ce lien pour réinitialiser votre mot de passe:</p>
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="padding: 10px 0 0px">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="${link}" style="line-height: 100%; text-decoration: none; display: block; max-width: 100%; background-color: #0052cd; border-radius: 3px; font-weight: 600; color: #fff; font-size: 15px; text-align: center; padding: 11px 23px 11px 23px" target="_blank"
|
||||
><span
|
||||
><!--[if mso]><i style="mso-font-width: 383.33333333333337%; mso-text-raise: 16.5" hidden>   </i><![endif]--></span
|
||||
><span style="max-width: 100%; display: inline-block; line-height: 120%; mso-padding-alt: 0px; mso-text-raise: 8.25px">Réinitialiser mot de passe</span
|
||||
><span
|
||||
><!--[if mso]><i style="mso-font-width: 383.33333333333337%" hidden>   ​</i><![endif]--></span
|
||||
></a
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<hr style="width: 100%; border: none; border-top: 1px solid #eaeaea; border-color: #dfe1e4; margin: 20px 0 20px" />
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tbody style="width: 100%">
|
||||
<tr style="width: 100%">
|
||||
<td data-id="__react-email-column" style="width: 30px"><img alt="Nhost Logo" height="20" src="https://nhost.io/images/emails/icon.png" style="display: block; outline: none; border: none; text-decoration: none; border-radius: 0; width: 20px; height: 20px" width="20" /></td>
|
||||
<td data-id="__react-email-column" style="margin: 0"><a href="https://nhost.io" style="color: #b4becc; text-decoration: none; font-size: 14px" target="_blank">Powered by Nhost</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1 +0,0 @@
|
||||
Réinitialiser votre mot de passe
|
||||
@@ -1,43 +0,0 @@
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<html dir="ltr" lang="en">
|
||||
<head>
|
||||
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
|
||||
<meta name="x-apple-disable-message-reformatting" />
|
||||
</head>
|
||||
<body style="background-color: #f5f5f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif">
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="max-width: 560px; margin: 20px auto 0 auto; padding: 20px; background-color: #ffffff; border-radius: 8px; border: 1px solid #ececec">
|
||||
<tbody>
|
||||
<tr style="width: 100%">
|
||||
<td>
|
||||
<h1 style="font-size: 24px; letter-spacing: -0.5px; line-height: 1.3; font-weight: 400; color: #484848; margin-top: 0">One-time Password</h1>
|
||||
<p style="font-size: 15px; line-height: 1.4; margin: 0 0 10px; color: #3c4149">Pour vous connecter à ${redirectTo}, veuillez utiliser le mot de passe à usage unique suivant :</p>
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="padding: 10px 0 0px">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><p style="font-size: 24px; line-height: 32px; margin: 16px 0; color: #0052cd; font-weight: 600">${ticket}</p></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<hr style="width: 100%; border: none; border-top: 1px solid #eaeaea; border-color: #dfe1e4; margin: 20px 0 20px" />
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tbody style="width: 100%">
|
||||
<tr style="width: 100%">
|
||||
<td data-id="__react-email-column" style="width: 30px"><img alt="Nhost Logo" height="20" src="https://nhost.io/images/emails/icon.png" style="display: block; outline: none; border: none; text-decoration: none; border-radius: 0; width: 20px; height: 20px" width="20" /></td>
|
||||
<td data-id="__react-email-column" style="margin: 0"><a href="https://nhost.io" style="color: #b4becc; text-decoration: none; font-size: 14px" target="_blank">Powered by Nhost</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1 +0,0 @@
|
||||
Mot de passe à usage unique pour ${redirectTo}
|
||||
@@ -1 +0,0 @@
|
||||
Votre code est ${code}.
|
||||
@@ -1,52 +0,0 @@
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<html dir="ltr" lang="en">
|
||||
<head>
|
||||
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
|
||||
<meta name="x-apple-disable-message-reformatting" />
|
||||
</head>
|
||||
<body style="background-color: #f5f5f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif">
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="max-width: 560px; margin: 20px auto 0 auto; padding: 20px; background-color: #ffffff; border-radius: 8px; border: 1px solid #ececec">
|
||||
<tbody>
|
||||
<tr style="width: 100%">
|
||||
<td>
|
||||
<h1 style="font-size: 24px; letter-spacing: -0.5px; line-height: 1.3; font-weight: 400; color: #484848; margin-top: 0">Lien magique</h1>
|
||||
<p style="font-size: 15px; line-height: 1.4; margin: 0 0 10px; color: #3c4149">Utilisez ce lien pour vous connecter de façon sécurisée:</p>
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="padding: 10px 0 0px">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="${link}" style="line-height: 100%; text-decoration: none; display: block; max-width: 100%; background-color: #0052cd; border-radius: 3px; font-weight: 600; color: #fff; font-size: 15px; text-align: center; padding: 11px 23px 11px 23px" target="_blank"
|
||||
><span
|
||||
><!--[if mso]><i style="mso-font-width: 383.33333333333337%; mso-text-raise: 16.5" hidden>   </i><![endif]--></span
|
||||
><span style="max-width: 100%; display: inline-block; line-height: 120%; mso-padding-alt: 0px; mso-text-raise: 8.25px">Connexion</span
|
||||
><span
|
||||
><!--[if mso]><i style="mso-font-width: 383.33333333333337%" hidden>   ​</i><![endif]--></span
|
||||
></a
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<hr style="width: 100%; border: none; border-top: 1px solid #eaeaea; border-color: #dfe1e4; margin: 20px 0 20px" />
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tbody style="width: 100%">
|
||||
<tr style="width: 100%">
|
||||
<td data-id="__react-email-column" style="width: 30px"><img alt="Nhost Logo" height="20" src="https://nhost.io/images/emails/icon.png" style="display: block; outline: none; border: none; text-decoration: none; border-radius: 0; width: 20px; height: 20px" width="20" /></td>
|
||||
<td data-id="__react-email-column" style="margin: 0"><a href="https://nhost.io" style="color: #b4becc; text-decoration: none; font-size: 14px" target="_blank">Powered by Nhost</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1 +0,0 @@
|
||||
Lien de connexion sécurisé
|
||||
@@ -1,8 +0,0 @@
|
||||
${link},
|
||||
${displayName},
|
||||
${email},
|
||||
${ticket},
|
||||
${redirectTo},
|
||||
${serverUrl},
|
||||
${clientUrl},
|
||||
${locale},
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user