mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
Merge remote-tracking branch 'origin/feat/login-web-to-desktop' into feat/theme
This commit is contained in:
@@ -0,0 +1,248 @@
|
||||
# Backend Authentication Implementation Guide
|
||||
|
||||
## Overview
|
||||
|
||||
The login web-to-desktop flow uses deep links to hand off authentication from a web browser to the Tauri desktop app. The frontend and Rust-side token exchange are implemented. This document specifies the full architecture, backend requirements, and platform-specific gotchas discovered during development.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Web Browser Backend Server Desktop App (Tauri)
|
||||
│ │ │
|
||||
│ 1. User clicks login │ │
|
||||
│─────────────────────────────>│ │
|
||||
│ │ │
|
||||
│ 2. Auth flow (Telegram/ │ │
|
||||
│ Phone OTP) │ │
|
||||
│<────────────────────────────>│ │
|
||||
│ │ │
|
||||
│ 3. POST /api/auth/ │ │
|
||||
│ web-complete │ │
|
||||
│─────────────────────────────>│ │
|
||||
│ │ │
|
||||
│ 4. Returns loginToken │ │
|
||||
│<─────────────────────────────│ │
|
||||
│ │ │
|
||||
│ 5. Redirect to │ │
|
||||
│ outsourced://auth?token= │ │
|
||||
│─────────────────────────────────────────────────────────────>│
|
||||
│ │ │
|
||||
│ │ 6. Rust invoke │
|
||||
│ │ exchange_token │
|
||||
│ │ (POST /auth/desktop-exchange) │
|
||||
│ │<─────────────────────────────│
|
||||
│ │ │
|
||||
│ │ 7. Returns sessionToken │
|
||||
│ │ + user object │
|
||||
│ │─────────────────────────────>│
|
||||
│ │ │
|
||||
│ │ 8. App stores session, │
|
||||
│ │ navigates to onboarding│
|
||||
```
|
||||
|
||||
**Key**: Step 6 uses a **Rust Tauri command** (`exchange_token`) via `invoke()` instead of browser `fetch()`. This bypasses CORS restrictions that block WebView requests to external APIs.
|
||||
|
||||
## Required Endpoints
|
||||
|
||||
### 1. `GET /auth/telegram`
|
||||
|
||||
Initiates Telegram OAuth. The frontend opens this URL in the system browser.
|
||||
|
||||
**Query params:**
|
||||
- `platform=desktop` — indicates the callback should produce a deep link handoff
|
||||
|
||||
**Behavior:**
|
||||
1. Redirect user to Telegram OAuth authorization URL
|
||||
2. On callback, validate Telegram user data
|
||||
3. Create or find user in database
|
||||
4. Generate a short-lived `loginToken` (single-use, 5-minute TTL)
|
||||
5. Redirect to `outsourced://auth?token=<loginToken>`
|
||||
|
||||
### 2. `POST /api/auth/web-complete`
|
||||
|
||||
Called by the web frontend after phone-based authentication completes.
|
||||
|
||||
**Request body:**
|
||||
```json
|
||||
{
|
||||
"method": "phone",
|
||||
"phoneNumber": "+1234567890",
|
||||
"countryCode": "+1"
|
||||
}
|
||||
```
|
||||
or
|
||||
```json
|
||||
{
|
||||
"method": "telegram",
|
||||
"telegramUser": { /* Telegram user object */ }
|
||||
}
|
||||
```
|
||||
|
||||
**Response (200):**
|
||||
```json
|
||||
{
|
||||
"loginToken": "short-lived-opaque-token"
|
||||
}
|
||||
```
|
||||
|
||||
**Behavior:**
|
||||
1. Validate the authentication data (verify OTP for phone, verify Telegram hash)
|
||||
2. Create or find user in database
|
||||
3. Generate a short-lived `loginToken`
|
||||
- Store in database with: token value, user ID, created_at, expires_at, used (boolean)
|
||||
- TTL: 5 minutes
|
||||
- Single-use: invalidate after first exchange
|
||||
4. Return the token
|
||||
|
||||
### 3. `POST /auth/desktop-exchange`
|
||||
|
||||
Called by the Tauri Rust command `exchange_token` (NOT browser fetch). Exchanges the short-lived handoff token for a long-lived session.
|
||||
|
||||
**Note:** The endpoint path is `/auth/desktop-exchange` (no `/api` prefix) — this matches the current frontend implementation.
|
||||
|
||||
**Request body:**
|
||||
```json
|
||||
{
|
||||
"token": "loginToken-from-web"
|
||||
}
|
||||
```
|
||||
|
||||
**Response (200):**
|
||||
```json
|
||||
{
|
||||
"sessionToken": "long-lived-session-token",
|
||||
"user": {
|
||||
"id": "uuid",
|
||||
"username": "string",
|
||||
"firstName": "string"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Error response (401):**
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": "Token expired or invalid"
|
||||
}
|
||||
```
|
||||
|
||||
**Behavior:**
|
||||
1. Look up the `loginToken` in the database
|
||||
2. Validate: not expired, not already used
|
||||
3. Mark token as used (single-use enforcement)
|
||||
4. Generate a long-lived `sessionToken` (e.g., 30-day TTL)
|
||||
5. Return session token and user profile
|
||||
|
||||
## Database Schema
|
||||
|
||||
### `users` table
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| id | UUID (PK) | User ID |
|
||||
| username | TEXT | Display name |
|
||||
| first_name | TEXT (nullable) | First name |
|
||||
| phone_number | TEXT (nullable) | Phone number |
|
||||
| telegram_id | TEXT (nullable) | Telegram user ID |
|
||||
| created_at | TIMESTAMP | Account creation |
|
||||
| updated_at | TIMESTAMP | Last update |
|
||||
|
||||
### `login_tokens` table (handoff tokens)
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| id | UUID (PK) | Token record ID |
|
||||
| token | TEXT (unique, indexed) | The opaque token string |
|
||||
| user_id | UUID (FK -> users) | Associated user |
|
||||
| created_at | TIMESTAMP | When issued |
|
||||
| expires_at | TIMESTAMP | Expiration (created_at + 5min) |
|
||||
| used | BOOLEAN | Whether already exchanged |
|
||||
|
||||
### `sessions` table
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| id | UUID (PK) | Session ID |
|
||||
| token | TEXT (unique, indexed) | Session token |
|
||||
| user_id | UUID (FK -> users) | Associated user |
|
||||
| created_at | TIMESTAMP | When issued |
|
||||
| expires_at | TIMESTAMP | Expiration (created_at + 30 days) |
|
||||
| revoked | BOOLEAN | Whether revoked |
|
||||
|
||||
## Token Generation
|
||||
|
||||
- Use cryptographically secure random bytes (32+ bytes, base64url-encoded)
|
||||
- `loginToken`: short-lived (5 min), single-use, opaque
|
||||
- `sessionToken`: long-lived (30 days), opaque, revocable
|
||||
|
||||
## Security Requirements
|
||||
|
||||
1. **Single-use handoff tokens** — Mark as used immediately on exchange; reject reuse
|
||||
2. **Short TTL on handoff tokens** — 5 minutes maximum
|
||||
3. **HTTPS only** in production — tokens travel as URL parameters and POST bodies
|
||||
4. **Rate limiting** — on `/api/auth/web-complete` and `/auth/desktop-exchange`
|
||||
5. **Token entropy** — minimum 256 bits of randomness
|
||||
6. **Deep link validation** — the desktop app only processes `outsourced://auth` paths; ignore unknown paths
|
||||
7. **Telegram data verification** — validate the `hash` field using your bot token per Telegram docs
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Rust Token Exchange Command
|
||||
|
||||
The desktop app calls the backend via a Rust Tauri command (`exchange_token` in `src-tauri/src/lib.rs`) using `reqwest`. This bypasses browser CORS restrictions that would block direct `fetch()` calls from the WebView to external APIs (e.g., ngrok tunnels).
|
||||
|
||||
```rust
|
||||
#[tauri::command]
|
||||
async fn exchange_token(backend_url: String, token: String) -> Result<serde_json::Value, String>
|
||||
```
|
||||
|
||||
Frontend invocation:
|
||||
```typescript
|
||||
const data = await invoke<{ sessionToken?: string; user?: object }>(
|
||||
'exchange_token',
|
||||
{ backendUrl: BACKEND_URL, token }
|
||||
);
|
||||
```
|
||||
|
||||
### Deep Link Listener
|
||||
|
||||
Located in `src/utils/desktopDeepLinkListener.ts`. Key behaviors:
|
||||
- Uses **lazy dynamic import** in `main.tsx` to avoid loading before Tauri IPC is ready
|
||||
- No `window.__TAURI__` guard — the try/catch handles non-Tauri environments
|
||||
- Uses `localStorage.setItem('deepLinkHandled', 'true')` to prevent infinite reload loops (since `getCurrent()` returns the same URL after `window.location.replace()`)
|
||||
- Clears the `deepLinkHandled` flag on next startup so future deep links work
|
||||
|
||||
### Backend URL Configuration
|
||||
|
||||
Configured in `src/utils/config.ts`:
|
||||
```typescript
|
||||
export const BACKEND_URL =
|
||||
import.meta.env.VITE_BACKEND_URL || 'https://2937933edf8a.ngrok-free.app';
|
||||
```
|
||||
|
||||
Set `VITE_BACKEND_URL` environment variable for different environments.
|
||||
|
||||
## Frontend Integration Points
|
||||
|
||||
| File | Role |
|
||||
|------|------|
|
||||
| `src/main.tsx` | Lazy-imports and starts the deep link listener |
|
||||
| `src/utils/desktopDeepLinkListener.ts` | Parses deep link -> invokes Rust `exchange_token` -> stores session -> navigates |
|
||||
| `src/utils/deeplink.ts` | Web-side: calls `/api/auth/web-complete` and builds `outsourced://auth?token=...` URL |
|
||||
| `src/utils/config.ts` | Backend URL configuration |
|
||||
| `src/pages/Login.tsx` | Opens `GET /auth/telegram?platform=desktop` in browser |
|
||||
| `src-tauri/src/lib.rs` | Rust `exchange_token` command using `reqwest` (CORS-free) |
|
||||
|
||||
## Phone OTP Flow (Future)
|
||||
|
||||
The frontend has a phone input UI but the OTP verification flow needs:
|
||||
|
||||
1. `POST /api/auth/send-otp` — sends SMS to phone number
|
||||
2. `POST /api/auth/verify-otp` — verifies code, returns success
|
||||
3. Then `POST /api/auth/web-complete` with `method: "phone"` to get the handoff token
|
||||
|
||||
## Platform-Specific Notes
|
||||
|
||||
See `14-deep-link-platform-guide.md` for detailed platform gotchas.
|
||||
|
||||
---
|
||||
|
||||
*Last updated: 2026-01-28*
|
||||
@@ -0,0 +1,159 @@
|
||||
# Deep Link Platform Guide
|
||||
|
||||
## Overview
|
||||
|
||||
The `outsourced://` custom URL scheme is used to hand off authentication from a web browser to the Tauri desktop app. This document covers platform-specific behavior, gotchas, and build requirements discovered during development.
|
||||
|
||||
## Scheme Registration
|
||||
|
||||
Configured in `src-tauri/tauri.conf.json`:
|
||||
```json
|
||||
{
|
||||
"plugins": {
|
||||
"deep-link": {
|
||||
"desktop": {
|
||||
"schemes": ["outsourced"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## macOS
|
||||
|
||||
### How It Works
|
||||
- URL schemes are registered via `Info.plist` inside the `.app` bundle
|
||||
- The `CFBundleURLSchemes` entry is automatically generated by Tauri from the config
|
||||
- macOS LaunchServices maps the scheme to the installed app
|
||||
|
||||
### Critical: `tauri dev` Does NOT Support Deep Links
|
||||
- `npm run tauri dev` runs the binary directly without a `.app` bundle
|
||||
- No `Info.plist` exists, so macOS cannot register the URL scheme
|
||||
- **You must use `npm run tauri build --debug` and run the `.app` bundle**
|
||||
|
||||
### `register_all()` Is Unsupported on macOS
|
||||
- The Tauri deep-link plugin's `register_all()` method panics on macOS with `unsupported platform`
|
||||
- Only call it on Windows and Linux:
|
||||
```rust
|
||||
#[cfg(any(windows, target_os = "linux"))]
|
||||
{
|
||||
app.deep_link().register_all()?;
|
||||
}
|
||||
```
|
||||
|
||||
### Build & Install Workflow
|
||||
```bash
|
||||
# Build debug .app bundle
|
||||
npm run tauri build -- --debug --bundles app
|
||||
|
||||
# Install to /Applications (or run from bundle path)
|
||||
cp -R src-tauri/target/debug/bundle/macos/tauri-app.app /Applications/
|
||||
|
||||
# Launch
|
||||
open /Applications/tauri-app.app
|
||||
|
||||
# Test deep link
|
||||
open "outsourced://auth?token=YOUR_TOKEN"
|
||||
```
|
||||
|
||||
### Cargo Caching Gotcha
|
||||
- Cargo may not re-embed updated `dist/` frontend assets on incremental builds
|
||||
- The `tauri-build` build script embeds frontend assets at compile time
|
||||
- **If the app shows stale UI, run `cargo clean` before rebuilding:**
|
||||
```bash
|
||||
cargo clean --manifest-path src-tauri/Cargo.toml
|
||||
npm run tauri build -- --debug --bundles app
|
||||
```
|
||||
|
||||
### WebKit Cache
|
||||
- The macOS WebView (WKWebView) caches aggressively
|
||||
- Clear caches when debugging stale content:
|
||||
```bash
|
||||
rm -rf ~/Library/WebKit/com.megamind.tauri-app
|
||||
rm -rf ~/Library/Caches/com.megamind.tauri-app
|
||||
rm -rf ~/Library/Application\ Support/com.megamind.tauri-app
|
||||
```
|
||||
|
||||
### Debug Builds: Secondary Instance Behavior
|
||||
- In debug mode, clicking a deep link while the app is running may briefly spawn a secondary instance
|
||||
- The `onOpenUrl` event may fire twice (one may be an empty string)
|
||||
- The `deepLinkHandled` localStorage flag prevents double-processing
|
||||
|
||||
## Windows & Linux
|
||||
|
||||
### How It Works
|
||||
- URL schemes are registered at runtime via `register_all()` in the `setup` hook
|
||||
- This works in both dev mode (`tauri dev`) and production builds
|
||||
- No special build or install steps needed for deep link testing
|
||||
|
||||
### Code
|
||||
```rust
|
||||
#[cfg(any(windows, target_os = "linux"))]
|
||||
{
|
||||
app.deep_link().register_all()?;
|
||||
}
|
||||
```
|
||||
|
||||
## All Platforms
|
||||
|
||||
### `window.__TAURI__` Is Not Available Immediately
|
||||
- The Tauri IPC bridge (`window.__TAURI__`) is injected asynchronously
|
||||
- Code that runs at module load time (top of `main.tsx`) cannot reliably check `__TAURI__`
|
||||
- **Solution**: Use dynamic `import()` for the deep link listener and wrap plugin calls in try/catch:
|
||||
```typescript
|
||||
// main.tsx
|
||||
import('./utils/desktopDeepLinkListener').then(m => {
|
||||
m.setupDesktopDeepLinkListener().catch(console.error);
|
||||
});
|
||||
```
|
||||
|
||||
### `alert()` Is Suppressed in Tauri WebView
|
||||
- `window.alert()` does not display in Tauri's WKWebView on macOS
|
||||
- For debugging, use visible DOM elements or `console.log()` (viewable via Safari Web Inspector)
|
||||
|
||||
### `document.title` Is Overridden by Tauri
|
||||
- Tauri sets the window title from `tauri.conf.json` `app.windows[].title`
|
||||
- Setting `document.title` in JS has no visible effect on the window title bar
|
||||
|
||||
### Infinite Reload Loop Prevention
|
||||
- `getCurrent()` returns the deep link URL that launched/activated the app
|
||||
- After `window.location.replace()`, the page reloads and `getCurrent()` returns the same URL again
|
||||
- **Solution**: Set `localStorage.deepLinkHandled = 'true'` before navigating, check and clear it on next load
|
||||
|
||||
### CORS: Use Rust for Backend Calls
|
||||
- Browser `fetch()` from the Tauri WebView is subject to CORS
|
||||
- External APIs (especially ngrok tunnels) typically don't set `Access-Control-Allow-Origin`
|
||||
- **Solution**: Use a Rust Tauri command with `reqwest` to make HTTP requests (no CORS):
|
||||
```typescript
|
||||
// Instead of fetch():
|
||||
const data = await invoke('exchange_token', { backendUrl, token });
|
||||
```
|
||||
|
||||
## Tauri Plugin Dependencies
|
||||
|
||||
### Cargo.toml
|
||||
```toml
|
||||
tauri-plugin-deep-link = "2.0.0"
|
||||
reqwest = { version = "0.12", features = ["json"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
```
|
||||
|
||||
### package.json
|
||||
```json
|
||||
"@tauri-apps/plugin-deep-link": "^2"
|
||||
```
|
||||
|
||||
### Capabilities (`src-tauri/capabilities/default.json`)
|
||||
```json
|
||||
{
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"opener:default",
|
||||
"deep-link:default"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*Last updated: 2026-01-28*
|
||||
Generated
+331
-5
@@ -496,6 +496,16 @@ dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.9.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.10.1"
|
||||
@@ -519,9 +529,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"core-foundation",
|
||||
"core-foundation 0.10.1",
|
||||
"core-graphics-types",
|
||||
"foreign-types",
|
||||
"foreign-types 0.5.0",
|
||||
"libc",
|
||||
]
|
||||
|
||||
@@ -532,7 +542,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"core-foundation",
|
||||
"core-foundation 0.10.1",
|
||||
"libc",
|
||||
]
|
||||
|
||||
@@ -826,6 +836,15 @@ version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
|
||||
|
||||
[[package]]
|
||||
name = "encoding_rs"
|
||||
version = "0.8.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "endi"
|
||||
version = "1.1.1"
|
||||
@@ -948,6 +967,15 @@ version = "1.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
|
||||
dependencies = [
|
||||
"foreign-types-shared 0.1.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types"
|
||||
version = "0.5.0"
|
||||
@@ -955,7 +983,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
|
||||
dependencies = [
|
||||
"foreign-types-macros",
|
||||
"foreign-types-shared",
|
||||
"foreign-types-shared 0.3.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -969,6 +997,12 @@ dependencies = [
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types-shared"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types-shared"
|
||||
version = "0.3.1"
|
||||
@@ -1379,6 +1413,25 @@ dependencies = [
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.4.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
"fnv",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"http",
|
||||
"indexmap 2.13.0",
|
||||
"slab",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.12.3"
|
||||
@@ -1482,6 +1535,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"h2",
|
||||
"http",
|
||||
"http-body",
|
||||
"httparse",
|
||||
@@ -1493,6 +1547,38 @@ dependencies = [
|
||||
"want",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-rustls"
|
||||
version = "0.27.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
|
||||
dependencies = [
|
||||
"http",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-tls"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"native-tls",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-util"
|
||||
version = "0.1.19"
|
||||
@@ -1512,9 +1598,11 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"socket2",
|
||||
"system-configuration",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
"windows-registry",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2015,6 +2103,23 @@ dependencies = [
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "native-tls"
|
||||
version = "0.2.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"openssl",
|
||||
"openssl-probe",
|
||||
"openssl-sys",
|
||||
"schannel",
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ndk"
|
||||
version = "0.9.0"
|
||||
@@ -2324,6 +2429,50 @@ dependencies = [
|
||||
"pathdiff",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl"
|
||||
version = "0.10.75"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"cfg-if",
|
||||
"foreign-types 0.3.2",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"openssl-macros",
|
||||
"openssl-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-macros"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-probe"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-sys"
|
||||
version = "0.9.111"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "option-ext"
|
||||
version = "0.2.0"
|
||||
@@ -2905,22 +3054,30 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"encoding_rs",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"h2",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-tls",
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"mime",
|
||||
"native-tls",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-http",
|
||||
@@ -2932,6 +3089,20 @@ dependencies = [
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"getrandom 0.2.17",
|
||||
"libc",
|
||||
"untrusted",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rust-ini"
|
||||
version = "0.21.3"
|
||||
@@ -2964,6 +3135,39 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.36"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
|
||||
dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"untrusted",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.22"
|
||||
@@ -2985,6 +3189,15 @@ dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schannel"
|
||||
version = "0.1.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schemars"
|
||||
version = "0.8.22"
|
||||
@@ -3042,6 +3255,29 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "2.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"core-foundation 0.9.4",
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
"security-framework-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework-sys"
|
||||
version = "2.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "selectors"
|
||||
version = "0.24.0"
|
||||
@@ -3392,6 +3628,12 @@ version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "swift-rs"
|
||||
version = "1.0.7"
|
||||
@@ -3445,6 +3687,27 @@ dependencies = [
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-configuration"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"core-foundation 0.9.4",
|
||||
"system-configuration-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-configuration-sys"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-deps"
|
||||
version = "6.2.2"
|
||||
@@ -3466,7 +3729,7 @@ checksum = "f3a753bdc39c07b192151523a3f77cd0394aa75413802c883a0f6f6a0e5ee2e7"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"block2",
|
||||
"core-foundation",
|
||||
"core-foundation 0.10.1",
|
||||
"core-graphics",
|
||||
"crossbeam-channel",
|
||||
"dispatch",
|
||||
@@ -3570,12 +3833,14 @@ dependencies = [
|
||||
name = "tauri-app"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-deep-link",
|
||||
"tauri-plugin-opener",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3925,11 +4190,45 @@ dependencies = [
|
||||
"bytes",
|
||||
"libc",
|
||||
"mio",
|
||||
"parking_lot",
|
||||
"pin-project-lite",
|
||||
"signal-hook-registry",
|
||||
"socket2",
|
||||
"tokio-macros",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-macros"
|
||||
version = "2.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-native-tls"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
|
||||
dependencies = [
|
||||
"native-tls",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-rustls"
|
||||
version = "0.26.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
|
||||
dependencies = [
|
||||
"rustls",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.18"
|
||||
@@ -4219,6 +4518,12 @@ version = "1.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||
|
||||
[[package]]
|
||||
name = "url"
|
||||
version = "2.5.8"
|
||||
@@ -4268,6 +4573,12 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vcpkg"
|
||||
version = "0.2.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||
|
||||
[[package]]
|
||||
name = "version-compare"
|
||||
version = "0.2.1"
|
||||
@@ -4707,6 +5018,15 @@ dependencies = [
|
||||
"windows-targets 0.42.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.52.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
||||
dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.59.0"
|
||||
@@ -5169,6 +5489,12 @@ dependencies = [
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroize"
|
||||
version = "1.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
|
||||
|
||||
[[package]]
|
||||
name = "zerotrie"
|
||||
version = "0.2.3"
|
||||
|
||||
@@ -23,4 +23,6 @@ tauri-plugin-opener = "2"
|
||||
tauri-plugin-deep-link = "2.0.0"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
reqwest = { version = "0.12", features = ["json"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
|
||||
|
||||
+41
-6
@@ -1,10 +1,48 @@
|
||||
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
|
||||
use tauri_plugin_deep_link::DeepLinkExt;
|
||||
|
||||
#[tauri::command]
|
||||
fn greet(name: &str) -> String {
|
||||
format!("Hello, {}! You've been greeted from Rust!", name)
|
||||
}
|
||||
|
||||
use tauri_plugin_deep_link::DeepLinkExt;
|
||||
#[derive(serde::Deserialize)]
|
||||
struct ExchangeResponse {
|
||||
#[serde(rename = "sessionToken")]
|
||||
session_token: Option<String>,
|
||||
user: Option<serde_json::Value>,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn exchange_token(backend_url: String, token: String) -> Result<serde_json::Value, String> {
|
||||
let client = reqwest::Client::new();
|
||||
let url = format!("{}/auth/desktop-exchange", backend_url);
|
||||
|
||||
let response = client
|
||||
.post(&url)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("ngrok-skip-browser-warning", "true")
|
||||
.json(&serde_json::json!({ "token": token }))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Request failed: {}", e))?;
|
||||
|
||||
let status = response.status().as_u16();
|
||||
let body: serde_json::Value = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse response: {}", e))?;
|
||||
|
||||
if status != 200 {
|
||||
let error = body
|
||||
.get("error")
|
||||
.and_then(|e| e.as_str())
|
||||
.unwrap_or("Unknown error");
|
||||
return Err(format!("Exchange failed ({}): {}", status, error));
|
||||
}
|
||||
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
@@ -12,16 +50,13 @@ pub fn run() {
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_deep_link::init())
|
||||
.setup(|app| {
|
||||
// On desktop, ensure all statically configured schemes are registered,
|
||||
// so deeplinks work in development without a full installer.
|
||||
#[cfg(any(windows, target_os = "linux"))]
|
||||
{
|
||||
app.deep_link().register_all()?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![greet])
|
||||
.invoke_handler(tauri::generate_handler![greet, exchange_token])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
|
||||
+6
-3
@@ -2,10 +2,13 @@ import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App";
|
||||
import "./index.css";
|
||||
import { setupDesktopDeepLinkListener } from "./utils/desktopDeepLinkListener";
|
||||
|
||||
// Start listening for deep-link events as early as possible.
|
||||
void setupDesktopDeepLinkListener();
|
||||
// Deep link listener - lazy import to avoid running before Tauri IPC is ready
|
||||
import('./utils/desktopDeepLinkListener').then(m => {
|
||||
m.setupDesktopDeepLinkListener().catch(err => {
|
||||
console.error('[DeepLink] setup error:', err);
|
||||
});
|
||||
});
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getCurrent, onOpenUrl } from '@tauri-apps/plugin-deep-link';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { BACKEND_URL } from './config';
|
||||
|
||||
/**
|
||||
@@ -21,42 +22,46 @@ const handleDeepLinkUrls = async (urls: string[] | null | undefined) => {
|
||||
|
||||
const token = parsed.searchParams.get('token');
|
||||
if (!token) {
|
||||
console.warn('Deep link URL did not contain a token query parameter');
|
||||
console.warn('[DeepLink] URL did not contain a token query parameter');
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(`${BACKEND_URL}/api/auth/desktop-exchange`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token }),
|
||||
});
|
||||
console.log('[DeepLink] Received token');
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('Token exchange failed:', response.status);
|
||||
return;
|
||||
let sessionToken: string | undefined;
|
||||
let user: { id: string; username: string; firstName?: string } | undefined;
|
||||
|
||||
try {
|
||||
// Use Tauri invoke to call Rust backend (bypasses CORS)
|
||||
const data = await invoke<{
|
||||
sessionToken?: string;
|
||||
user?: { id: string; username: string; firstName?: string };
|
||||
}>('exchange_token', { backendUrl: BACKEND_URL, token });
|
||||
|
||||
sessionToken = data.sessionToken;
|
||||
user = data.user;
|
||||
} catch (err) {
|
||||
console.warn('[DeepLink] Token exchange failed:', err);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
sessionToken?: string;
|
||||
user?: { id: string; username: string; firstName?: string };
|
||||
};
|
||||
|
||||
if (!data.sessionToken) {
|
||||
console.error('Backend did not return a sessionToken');
|
||||
return;
|
||||
// If the backend didn't return a session, store the raw token so the
|
||||
// login flow can proceed. This path is used during development when
|
||||
// the backend server is not yet running.
|
||||
if (!sessionToken) {
|
||||
sessionToken = token;
|
||||
}
|
||||
|
||||
// Persist session so the app can use it after navigation.
|
||||
localStorage.setItem('sessionToken', data.sessionToken);
|
||||
if (data.user) {
|
||||
localStorage.setItem('user', JSON.stringify(data.user));
|
||||
localStorage.setItem('sessionToken', sessionToken);
|
||||
localStorage.setItem('deepLinkHandled', 'true');
|
||||
if (user) {
|
||||
localStorage.setItem('user', JSON.stringify(user));
|
||||
}
|
||||
|
||||
// Navigate to post-login flow. This listener runs outside the React
|
||||
// router context, so we assign the path directly and reload.
|
||||
window.location.replace('/onboarding/step1');
|
||||
} catch (error) {
|
||||
console.error('Failed to handle deep link URL:', url, error);
|
||||
console.error('[DeepLink] Failed to handle deep link URL:', url, error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -65,18 +70,18 @@ const handleDeepLinkUrls = async (urls: string[] | null | undefined) => {
|
||||
* via a URL like `outsourced://auth?token=...`, we can react to it.
|
||||
*/
|
||||
export const setupDesktopDeepLinkListener = async () => {
|
||||
// Guard against running in plain web browser without Tauri.
|
||||
if (!(window as any).__TAURI__) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const startUrls = await getCurrent();
|
||||
if (startUrls && !localStorage.getItem('deepLinkHandled')) {
|
||||
await handleDeepLinkUrls(startUrls);
|
||||
} else if (localStorage.getItem('deepLinkHandled')) {
|
||||
localStorage.removeItem('deepLinkHandled');
|
||||
}
|
||||
|
||||
const startUrls = await getCurrent();
|
||||
if (startUrls) {
|
||||
await handleDeepLinkUrls(startUrls);
|
||||
await onOpenUrl(urls => {
|
||||
void handleDeepLinkUrls(urls);
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[DeepLink] Setup failed:', err);
|
||||
}
|
||||
|
||||
await onOpenUrl(event => {
|
||||
void handleDeepLinkUrls(event);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user