global fix and add tauri app
This commit is contained in:
64
tauri_app/Dockerfile.apk
Normal file
64
tauri_app/Dockerfile.apk
Normal file
@@ -0,0 +1,64 @@
|
||||
FROM ubuntu:24.04
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
ENV ANDROID_HOME=/opt/android-sdk
|
||||
ENV ANDROID_SDK_ROOT=/opt/android-sdk
|
||||
ENV ANDROID_NDK_HOME=/opt/android-sdk/ndk/27.0.12077973
|
||||
ENV NDK_HOME=/opt/android-sdk/ndk/27.0.12077973
|
||||
ENV JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64
|
||||
ENV PATH=/root/.cargo/bin:/opt/android-sdk/cmdline-tools/latest/bin:/opt/android-sdk/platform-tools:/usr/local/bin:$PATH
|
||||
|
||||
ARG NPM_REGISTRY=https://registry.npmjs.org
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
curl \
|
||||
unzip \
|
||||
xz-utils \
|
||||
git \
|
||||
build-essential \
|
||||
pkg-config \
|
||||
libssl-dev \
|
||||
openjdk-17-jdk \
|
||||
nodejs \
|
||||
npm \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN mkdir -p ${ANDROID_HOME}/cmdline-tools \
|
||||
&& curl -fsSL https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip -o /tmp/android-tools.zip \
|
||||
&& unzip -q /tmp/android-tools.zip -d /tmp/android-tools \
|
||||
&& mv /tmp/android-tools/cmdline-tools ${ANDROID_HOME}/cmdline-tools/latest \
|
||||
&& rm -rf /tmp/android-tools /tmp/android-tools.zip
|
||||
|
||||
RUN yes | sdkmanager --licenses >/dev/null \
|
||||
&& sdkmanager \
|
||||
"platform-tools" \
|
||||
"platforms;android-35" \
|
||||
"build-tools;35.0.0" \
|
||||
"ndk;27.0.12077973"
|
||||
|
||||
RUN curl --proto '=https' --tlsv1.2 -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal \
|
||||
&& rustup target add aarch64-linux-android armv7-linux-androideabi i686-linux-android x86_64-linux-android
|
||||
|
||||
WORKDIR /workspace
|
||||
|
||||
COPY package.json package-lock.json .npmrc ./
|
||||
COPY apps/sso-core/package.json ./apps/sso-core/
|
||||
COPY apps/api-gateway/package.json ./apps/api-gateway/
|
||||
COPY apps/frontend/package.json ./apps/frontend/
|
||||
COPY apps/docs/package.json ./apps/docs/
|
||||
COPY tauri_app/package.json ./tauri_app/
|
||||
|
||||
RUN --mount=type=cache,target=/root/.npm \
|
||||
npm config set registry "${NPM_REGISTRY}" && \
|
||||
npm ci --no-audit --no-fund
|
||||
|
||||
COPY apps/frontend ./apps/frontend
|
||||
COPY shared ./shared
|
||||
COPY tsconfig.base.json ./
|
||||
COPY tauri_app ./tauri_app
|
||||
COPY tauri_app/docker/build-android-apk.sh /usr/local/bin/build-android-apk
|
||||
|
||||
RUN chmod +x /usr/local/bin/build-android-apk
|
||||
|
||||
CMD ["build-android-apk"]
|
||||
62
tauri_app/README.md
Normal file
62
tauri_app/README.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# Lendry ID Super App
|
||||
|
||||
Tauri v2 приложение для macOS, iOS и Android. Использует тот же React/Tailwind/shadcn UI стиль, что и `apps/frontend`, через Vite alias:
|
||||
|
||||
```ts
|
||||
'@': '../apps/frontend'
|
||||
```
|
||||
|
||||
## Запуск
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm --workspace @lendry/tauri-app run tauri:dev
|
||||
```
|
||||
|
||||
## Web build
|
||||
|
||||
```bash
|
||||
npm --workspace @lendry/tauri-app run build
|
||||
```
|
||||
|
||||
## Mobile bootstrap
|
||||
|
||||
```bash
|
||||
npm --workspace @lendry/tauri-app run android:init
|
||||
npm --workspace @lendry/tauri-app run ios:init
|
||||
```
|
||||
|
||||
После генерации проверьте `src-tauri/mobile-permissions.md` и добавьте camera permissions, если CLI не внёс их автоматически.
|
||||
|
||||
## Docker APK build
|
||||
|
||||
При запуске общего `docker compose up --build` сервис `tauri-apk-builder` собирает Android APK и кладёт файл сюда:
|
||||
|
||||
```text
|
||||
apps/frontend/public/downloads/lendry-id.apk
|
||||
```
|
||||
|
||||
После этого кнопка «Скачать приложение» на странице входа скачивает APK через:
|
||||
|
||||
```text
|
||||
/download/android
|
||||
```
|
||||
|
||||
Собрать только APK:
|
||||
|
||||
```bash
|
||||
docker compose up --build tauri-apk-builder
|
||||
```
|
||||
|
||||
## Переменные окружения
|
||||
|
||||
```bash
|
||||
VITE_API_URL=https://sso.example.ru/idp-api
|
||||
VITE_FRONTEND_URL=https://sso.example.ru
|
||||
```
|
||||
|
||||
## Реализованные модули
|
||||
|
||||
- `Chats`: wrapper существующего web UI `/family`, чтобы не дублировать messenger.
|
||||
- `Security`: QR scanner + approve `/auth/advanced/qr/session/:sessionId/approve`, список сессий и revoke.
|
||||
- `Authenticator`: offline TOTP RFC 6238 с хранением seeds в `tauri-plugin-stronghold`.
|
||||
36
tauri_app/docker/build-android-apk.sh
Normal file
36
tauri_app/docker/build-android-apk.sh
Normal file
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
cd /workspace
|
||||
|
||||
export VITE_API_URL="${VITE_API_URL:-${PUBLIC_API_URL:-http://localhost:3000}}"
|
||||
export VITE_FRONTEND_URL="${VITE_FRONTEND_URL:-${PUBLIC_FRONTEND_URL:-http://localhost:3002}}"
|
||||
|
||||
mkdir -p /out
|
||||
|
||||
echo "==> Проверка web-сборки tauri_app"
|
||||
npm --workspace @lendry/tauri-app run build
|
||||
|
||||
cd /workspace/tauri_app
|
||||
|
||||
if [ ! -d "src-tauri/gen/android" ]; then
|
||||
echo "==> Инициализация Android проекта Tauri"
|
||||
npm run android:init -- --ci || npm run android:init
|
||||
fi
|
||||
|
||||
echo "==> Сборка Android APK"
|
||||
npm run tauri -- android build --apk
|
||||
|
||||
APK_PATH="$(find /workspace/tauri_app/src-tauri/gen/android -type f -name '*.apk' | sort | tail -n 1 || true)"
|
||||
|
||||
if [ -z "${APK_PATH}" ] || [ ! -f "${APK_PATH}" ]; then
|
||||
echo "APK не найден после сборки" >&2
|
||||
find /workspace/tauri_app/src-tauri/gen/android -maxdepth 8 -type f | sort >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cp "${APK_PATH}" /out/lendry-id.apk
|
||||
chmod 0644 /out/lendry-id.apk
|
||||
|
||||
echo "==> APK готов: /out/lendry-id.apk"
|
||||
ls -lh /out/lendry-id.apk
|
||||
13
tauri_app/index.html
Normal file
13
tauri_app/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#111827" />
|
||||
<title>IDP App</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
45
tauri_app/package.json
Normal file
45
tauri_app/package.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "@lendry/tauri-app",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri",
|
||||
"tauri:dev": "tauri dev",
|
||||
"tauri:build": "tauri build",
|
||||
"android:init": "tauri android init",
|
||||
"android:dev": "tauri android dev",
|
||||
"ios:init": "tauri ios init",
|
||||
"ios:dev": "tauri ios dev"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@tauri-apps/api": "^2.9.0",
|
||||
"@tauri-apps/plugin-opener": "^2.5.0",
|
||||
"@tauri-apps/plugin-stronghold": "^2.3.0",
|
||||
"@zxing/browser": "^0.1.5",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.561.0",
|
||||
"react": "^19.2.1",
|
||||
"react-dom": "^19.2.1",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"zod": "^4.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@tauri-apps/cli": "^2.9.0",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.2",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.0"
|
||||
}
|
||||
}
|
||||
30
tauri_app/src-tauri/Cargo.toml
Normal file
30
tauri_app/src-tauri/Cargo.toml
Normal file
@@ -0,0 +1,30 @@
|
||||
[package]
|
||||
name = "lendry_id_super_app"
|
||||
version = "0.1.0"
|
||||
description = "Lendry ID Super App"
|
||||
authors = ["Lendry"]
|
||||
edition = "2021"
|
||||
rust-version = "1.77"
|
||||
|
||||
[lib]
|
||||
name = "lendry_id_super_app_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-stronghold = "2"
|
||||
url = "2"
|
||||
dirs-next = "2"
|
||||
|
||||
[profile.release]
|
||||
panic = "abort"
|
||||
codegen-units = 1
|
||||
lto = true
|
||||
opt-level = "s"
|
||||
strip = true
|
||||
3
tauri_app/src-tauri/build.rs
Normal file
3
tauri_app/src-tauri/build.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build();
|
||||
}
|
||||
11
tauri_app/src-tauri/capabilities/default.json
Normal file
11
tauri_app/src-tauri/capabilities/default.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Основные разрешения Lendry ID Super App",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"opener:default",
|
||||
"stronghold:default"
|
||||
]
|
||||
}
|
||||
30
tauri_app/src-tauri/mobile-permissions.md
Normal file
30
tauri_app/src-tauri/mobile-permissions.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# Mobile Permissions
|
||||
|
||||
После генерации native-проектов командами:
|
||||
|
||||
```bash
|
||||
npm run android:init
|
||||
npm run ios:init
|
||||
```
|
||||
|
||||
проверьте, что Tauri CLI добавил разрешения. Если нет, внесите их вручную.
|
||||
|
||||
## Android
|
||||
|
||||
`src-tauri/gen/android/app/src/main/AndroidManifest.xml`:
|
||||
|
||||
```xml
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
```
|
||||
|
||||
## iOS
|
||||
|
||||
`src-tauri/gen/apple/<target>/Info.plist`:
|
||||
|
||||
```xml
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Камера нужна для сканирования QR-кода входа Lendry ID.</string>
|
||||
```
|
||||
|
||||
QR-сканер использует HTML5 WebRTC внутри WebView. TOTP-секреты сохраняются через `tauri-plugin-stronghold`, а не в LocalStorage.
|
||||
148
tauri_app/src-tauri/src/lib.rs
Normal file
148
tauri_app/src-tauri/src/lib.rs
Normal file
@@ -0,0 +1,148 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::Manager;
|
||||
use url::Url;
|
||||
|
||||
const MAX_LABEL_LEN: usize = 80;
|
||||
const MAX_ISSUER_LEN: usize = 80;
|
||||
const MIN_TOTP_SECRET_LEN: usize = 16;
|
||||
const MAX_TOTP_SECRET_LEN: usize = 256;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ValidationResult {
|
||||
ok: bool,
|
||||
value: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct TotpProfileInput {
|
||||
issuer: String,
|
||||
label: String,
|
||||
secret: String,
|
||||
digits: Option<u8>,
|
||||
period: Option<u16>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn validate_qr_session_payload(payload: String) -> Result<ValidationResult, String> {
|
||||
let trimmed = payload.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err("QR-код пустой".into());
|
||||
}
|
||||
|
||||
let session_id = match Url::parse(trimmed) {
|
||||
Ok(url) => url
|
||||
.query_pairs()
|
||||
.find(|(key, _)| key == "sessionId" || key == "session_id")
|
||||
.map(|(_, value)| value.to_string())
|
||||
.or_else(|| {
|
||||
url.path_segments()
|
||||
.and_then(|segments| segments.last().map(ToString::to_string))
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
Err(_) => trimmed.to_string(),
|
||||
};
|
||||
|
||||
validate_uuid_like(&session_id)?;
|
||||
Ok(ValidationResult {
|
||||
ok: true,
|
||||
value: session_id,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn validate_api_base_url(url: String) -> Result<ValidationResult, String> {
|
||||
let parsed = Url::parse(url.trim()).map_err(|_| "Некорректный URL API".to_string())?;
|
||||
match parsed.scheme() {
|
||||
"http" | "https" => Ok(ValidationResult {
|
||||
ok: true,
|
||||
value: parsed.as_str().trim_end_matches('/').to_string(),
|
||||
}),
|
||||
_ => Err("URL API должен начинаться с http:// или https://".into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn validate_totp_profile_input(input: TotpProfileInput) -> Result<ValidationResult, String> {
|
||||
let issuer = input.issuer.trim();
|
||||
let label = input.label.trim();
|
||||
let secret = normalize_base32_secret(&input.secret);
|
||||
let digits = input.digits.unwrap_or(6);
|
||||
let period = input.period.unwrap_or(30);
|
||||
|
||||
if issuer.is_empty() || issuer.len() > MAX_ISSUER_LEN {
|
||||
return Err("Название сервиса должно содержать от 1 до 80 символов".into());
|
||||
}
|
||||
if label.is_empty() || label.len() > MAX_LABEL_LEN {
|
||||
return Err("Название аккаунта должно содержать от 1 до 80 символов".into());
|
||||
}
|
||||
if secret.len() < MIN_TOTP_SECRET_LEN || secret.len() > MAX_TOTP_SECRET_LEN {
|
||||
return Err("TOTP secret должен содержать от 16 до 256 символов base32".into());
|
||||
}
|
||||
if !secret.chars().all(|ch| matches!(ch, 'A'..='Z' | '2'..='7' | '=')) {
|
||||
return Err("TOTP secret должен быть в формате base32".into());
|
||||
}
|
||||
if digits != 6 && digits != 8 {
|
||||
return Err("TOTP поддерживает только 6 или 8 цифр".into());
|
||||
}
|
||||
if !(15..=120).contains(&period) {
|
||||
return Err("Период TOTP должен быть от 15 до 120 секунд".into());
|
||||
}
|
||||
|
||||
Ok(ValidationResult {
|
||||
ok: true,
|
||||
value: secret,
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_uuid_like(value: &str) -> Result<(), String> {
|
||||
let normalized = value.trim();
|
||||
if normalized.len() != 36 {
|
||||
return Err("QR-код не содержит корректный UUID сессии".into());
|
||||
}
|
||||
for (index, ch) in normalized.chars().enumerate() {
|
||||
let dash = matches!(index, 8 | 13 | 18 | 23);
|
||||
if dash && ch != '-' {
|
||||
return Err("QR-код не содержит корректный UUID сессии".into());
|
||||
}
|
||||
if !dash && !ch.is_ascii_hexdigit() {
|
||||
return Err("QR-код не содержит корректный UUID сессии".into());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn normalize_base32_secret(secret: &str) -> String {
|
||||
secret
|
||||
.trim()
|
||||
.chars()
|
||||
.filter(|ch| !ch.is_whitespace() && *ch != '-')
|
||||
.map(|ch| ch.to_ascii_uppercase())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_stronghold::Builder::new(|password| {
|
||||
use tauri_plugin_stronghold::stronghold::Client;
|
||||
|
||||
let salt_path = dirs_next::data_dir()
|
||||
.unwrap_or_else(std::env::temp_dir)
|
||||
.join("lendry-id-stronghold.salt");
|
||||
tauri_plugin_stronghold::stronghold::derive_key_from_password(password.as_ref(), &salt_path)
|
||||
})
|
||||
.build())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
validate_qr_session_payload,
|
||||
validate_api_base_url,
|
||||
validate_totp_profile_input
|
||||
])
|
||||
.setup(|app| {
|
||||
let _ = app.path().app_data_dir().map(std::fs::create_dir_all);
|
||||
Ok(())
|
||||
})
|
||||
.run(tauri::generate_context!())
|
||||
.expect("ошибка запуска Lendry ID Super App");
|
||||
}
|
||||
3
tauri_app/src-tauri/src/main.rs
Normal file
3
tauri_app/src-tauri/src/main.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
lendry_id_super_app_lib::run();
|
||||
}
|
||||
45
tauri_app/src-tauri/tauri.conf.json
Normal file
45
tauri_app/src-tauri/tauri.conf.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Lendry ID",
|
||||
"version": "0.1.0",
|
||||
"identifier": "ru.lendry.id.superapp",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeBuildCommand": "npm run build",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"withGlobalTauri": false,
|
||||
"windows": [
|
||||
{
|
||||
"title": "Lendry ID",
|
||||
"width": 1180,
|
||||
"height": 820,
|
||||
"minWidth": 390,
|
||||
"minHeight": 720,
|
||||
"resizable": true,
|
||||
"fullscreen": false
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; connect-src 'self' http://localhost:* https://* ws://localhost:* wss://*; img-src 'self' data: blob: https://*; media-src 'self' blob:; style-src 'self' 'unsafe-inline'; script-src 'self';"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": ["dmg", "app", "msi", "deb", "rpm"],
|
||||
"category": "Productivity",
|
||||
"shortDescription": "Мессенджер, SSO-аутентификатор и TOTP в одном приложении",
|
||||
"longDescription": "Lendry ID Super App объединяет семейный мессенджер, QR-подтверждение входа и безопасное хранилище TOTP-кодов.",
|
||||
"ios": {
|
||||
"developmentTeam": ""
|
||||
},
|
||||
"android": {
|
||||
"minSdkVersion": 24
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"opener": {}
|
||||
}
|
||||
}
|
||||
32
tauri_app/src/App.tsx
Normal file
32
tauri_app/src/App.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { useState } from 'react';
|
||||
import { MobileShell, type MobileTab } from './components/mobile-shell';
|
||||
import { LoginScreen } from './features/auth/login-screen';
|
||||
import { ChatsScreen } from './features/chat/chats-screen';
|
||||
import { SecurityScreen } from './features/security/security-screen';
|
||||
import { TotpScreen } from './features/totp/totp-screen';
|
||||
import { MobileAuthProvider, useMobileAuth } from './lib/auth';
|
||||
|
||||
function AppContent() {
|
||||
const { isAuthenticated } = useMobileAuth();
|
||||
const [activeTab, setActiveTab] = useState<MobileTab>('security');
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <LoginScreen />;
|
||||
}
|
||||
|
||||
return (
|
||||
<MobileShell activeTab={activeTab} onTabChange={setActiveTab}>
|
||||
{activeTab === 'chats' ? <ChatsScreen /> : null}
|
||||
{activeTab === 'totp' ? <TotpScreen /> : null}
|
||||
{activeTab === 'security' ? <SecurityScreen /> : null}
|
||||
</MobileShell>
|
||||
);
|
||||
}
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<MobileAuthProvider>
|
||||
<AppContent />
|
||||
</MobileAuthProvider>
|
||||
);
|
||||
}
|
||||
76
tauri_app/src/components/mobile-shell.tsx
Normal file
76
tauri_app/src/components/mobile-shell.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { KeyRound, MessageCircle, ShieldCheck } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export type MobileTab = 'chats' | 'totp' | 'security';
|
||||
|
||||
const tabs: Array<{ id: MobileTab; label: string; icon: React.ComponentType<{ className?: string }> }> = [
|
||||
{ id: 'chats', label: 'Чаты', icon: MessageCircle },
|
||||
{ id: 'totp', label: 'Коды', icon: KeyRound },
|
||||
{ id: 'security', label: 'Защита', icon: ShieldCheck }
|
||||
];
|
||||
|
||||
export function MobileShell({
|
||||
activeTab,
|
||||
onTabChange,
|
||||
children
|
||||
}: {
|
||||
activeTab: MobileTab;
|
||||
onTabChange: (tab: MobileTab) => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#eef1f6] text-[#1f2430]">
|
||||
<div className="mx-auto flex min-h-screen max-w-6xl">
|
||||
<aside className="hidden w-72 border-r border-[#dce3ec] bg-white/80 p-4 backdrop-blur lg:block">
|
||||
<div className="mb-8 rounded-[24px] bg-[#20212b] p-5 text-white shadow-xl">
|
||||
<p className="text-sm text-white/70">Super App</p>
|
||||
<h1 className="mt-1 text-2xl font-semibold">Lendry ID</h1>
|
||||
</div>
|
||||
<nav className="space-y-2">
|
||||
{tabs.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex w-full items-center gap-3 rounded-2xl px-4 py-3 text-left text-sm font-medium transition',
|
||||
activeTab === tab.id ? 'bg-[#20212b] text-white' : 'text-[#667085] hover:bg-[#f4f5f8] hover:text-[#1f2430]'
|
||||
)}
|
||||
onClick={() => onTabChange(tab.id)}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
{tab.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<main className="min-w-0 flex-1 pb-[88px] lg:pb-0">{children}</main>
|
||||
</div>
|
||||
|
||||
<nav className="safe-bottom fixed inset-x-0 bottom-0 z-50 border-t border-[#dce3ec] bg-white/95 px-4 py-2 shadow-[0_-12px_28px_rgba(31,36,48,0.12)] backdrop-blur lg:hidden">
|
||||
<div className="mx-auto grid max-w-md grid-cols-3 gap-2">
|
||||
{tabs.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex flex-col items-center gap-1 rounded-2xl px-2 py-2 text-xs font-medium transition',
|
||||
activeTab === tab.id ? 'bg-[#20212b] text-white' : 'text-[#667085]'
|
||||
)}
|
||||
onClick={() => onTabChange(tab.id)}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
{tab.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
159
tauri_app/src/features/auth/login-screen.tsx
Normal file
159
tauri_app/src/features/auth/login-screen.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import { useState } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { KeyRound, Loader2, ShieldCheck } from 'lucide-react';
|
||||
import { BrandLogo, ProjectTagline } from '@/components/id/brand-logo';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { beginTotpLogin, getMobileApiUrl, loginWithPassword, setMobileApiUrl, verifyTotpLogin } from '~/lib/mobile-api';
|
||||
import { useMobileAuth } from '~/lib/auth';
|
||||
import { getErrorMessage } from '~/lib/errors';
|
||||
|
||||
export function LoginScreen() {
|
||||
const { applyAuth } = useMobileAuth();
|
||||
const [apiUrl, setApiUrl] = useState(getMobileApiUrl());
|
||||
const [loginValue, setLoginValue] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [totpCode, setTotpCode] = useState('');
|
||||
const [totpChallengeToken, setTotpChallengeToken] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function submit() {
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const validated = await invoke<{ value: string }>('validate_api_base_url', { url: apiUrl });
|
||||
setMobileApiUrl(validated.value);
|
||||
const auth = await loginWithPassword(loginValue.trim(), password);
|
||||
if ('requiresTotp' in auth) {
|
||||
if (!auth.totpChallengeToken) {
|
||||
throw new Error('Сервер не вернул TOTP challenge');
|
||||
}
|
||||
setTotpChallengeToken(auth.totpChallengeToken);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
applyAuth(auth);
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err, 'Не удалось войти'));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function startTotpLogin() {
|
||||
if (!loginValue.trim()) {
|
||||
setError('Укажите почту, телефон или логин');
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const validated = await invoke<{ value: string }>('validate_api_base_url', { url: apiUrl });
|
||||
setMobileApiUrl(validated.value);
|
||||
const response = await beginTotpLogin(loginValue.trim());
|
||||
setTotpChallengeToken(response.totpChallengeToken);
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err, 'Не удалось начать TOTP-вход'));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitTotp() {
|
||||
if (!totpChallengeToken) return;
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const auth = await verifyTotpLogin(totpChallengeToken, totpCode.trim());
|
||||
applyAuth(auth);
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err, 'Неверный код аутентификатора'));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-[radial-gradient(circle_at_center,#596075_0%,#2b2534_45%,#121017_100%)] p-4">
|
||||
<div className="w-full max-w-[430px] rounded-[34px] bg-[#1f2028] px-8 pb-7 pt-9 text-white shadow-2xl">
|
||||
<div className="mb-8 flex flex-col items-center text-center">
|
||||
<BrandLogo size="lg" variant="light" />
|
||||
<h1 className="mt-8 text-xl font-bold">Войдите с ID</h1>
|
||||
<p className="mt-2 text-sm text-[#b9bdc9]">Мессенджер, QR-вход и TOTP-коды в одном приложении</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-[#b9bdc9]">API Gateway</label>
|
||||
<Input
|
||||
value={apiUrl}
|
||||
onChange={(event) => setApiUrl(event.target.value)}
|
||||
placeholder="https://sso.example.ru/idp-api"
|
||||
className="h-[54px] border-[#555762] bg-transparent text-white placeholder:text-[#8f92a0]"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-[#b9bdc9]">Почта, телефон или логин</label>
|
||||
<Input
|
||||
value={loginValue}
|
||||
onChange={(event) => setLoginValue(event.target.value)}
|
||||
autoComplete="username"
|
||||
className="h-[54px] border-[#555762] bg-transparent text-white placeholder:text-[#8f92a0]"
|
||||
/>
|
||||
</div>
|
||||
{totpChallengeToken ? (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-[#b9bdc9]">Код из приложения-аутентификатора</label>
|
||||
<Input
|
||||
value={totpCode}
|
||||
onChange={(event) => setTotpCode(event.target.value.replace(/\D/g, '').slice(0, 8))}
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
className="h-[58px] border-[#555762] bg-transparent text-center text-2xl tracking-[0.35em] text-white placeholder:text-[#8f92a0]"
|
||||
placeholder="000000"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-[#b9bdc9]">Пароль</label>
|
||||
<Input
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
className="h-[54px] border-[#555762] bg-transparent text-white placeholder:text-[#8f92a0]"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error ? <p className="mt-4 rounded-2xl bg-red-50 px-4 py-3 text-sm text-red-700">{error}</p> : null}
|
||||
|
||||
{totpChallengeToken ? (
|
||||
<>
|
||||
<Button variant="white" size="lg" className="mt-6 w-full rounded-[18px] text-base" disabled={submitting || totpCode.trim().length < 6} onClick={() => void submitTotp()}>
|
||||
{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : <ShieldCheck className="h-4 w-4" />}
|
||||
Подтвердить TOTP
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="lg" className="mt-3 w-full rounded-[18px] border-[#424550] text-white hover:bg-[#2a2c36]" onClick={() => setTotpChallengeToken(null)}>
|
||||
Вернуться к паролю
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button variant="white" size="lg" className="mt-6 w-full rounded-[18px] text-base" disabled={submitting || !loginValue.trim() || !password} onClick={() => void submit()}>
|
||||
{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : <KeyRound className="h-4 w-4" />}
|
||||
Войти
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="lg" className="mt-3 w-full rounded-[18px] border-[#424550] text-white hover:bg-[#2a2c36]" disabled={submitting || !loginValue.trim()} onClick={() => void startTotpLogin()}>
|
||||
<ShieldCheck className="h-4 w-4" />
|
||||
Войти через TOTP
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<ProjectTagline className="mt-8 text-center text-sm font-semibold" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
107
tauri_app/src/features/auth/qr-scanner.tsx
Normal file
107
tauri_app/src/features/auth/qr-scanner.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { BrowserMultiFormatReader, type IScannerControls } from '@zxing/browser';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { Camera, CheckCircle2, Loader2, QrCode, XCircle } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { approveQrLogin } from '~/lib/mobile-api';
|
||||
import { useMobileAuth } from '~/lib/auth';
|
||||
import { getErrorMessage } from '~/lib/errors';
|
||||
|
||||
export function QrScanner() {
|
||||
const { token } = useMobileAuth();
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const controlsRef = useRef<IScannerControls | null>(null);
|
||||
const [scanning, setScanning] = useState(false);
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const [approving, setApproving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
controlsRef.current?.stop();
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function approvePayload(payload: string) {
|
||||
if (!token || approving) return;
|
||||
setApproving(true);
|
||||
setStatus('Проверяем QR-код...');
|
||||
try {
|
||||
const validated = await invoke<{ value: string }>('validate_qr_session_payload', { payload });
|
||||
await approveQrLogin(validated.value, token);
|
||||
setStatus('Вход на сайте подтверждён');
|
||||
controlsRef.current?.stop();
|
||||
setScanning(false);
|
||||
} catch (error) {
|
||||
setStatus(getErrorMessage(error, 'Не удалось подтвердить вход'));
|
||||
} finally {
|
||||
setApproving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function startScan() {
|
||||
if (!videoRef.current) return;
|
||||
setStatus(null);
|
||||
setScanning(true);
|
||||
const reader = new BrowserMultiFormatReader();
|
||||
try {
|
||||
controlsRef.current = await reader.decodeFromVideoDevice(undefined, videoRef.current, (result, error) => {
|
||||
if (result?.getText()) {
|
||||
void approvePayload(result.getText());
|
||||
} else if (error) {
|
||||
setStatus('Камера активна, наведите её на QR-код');
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
setStatus(getErrorMessage(error, 'Не удалось открыть камеру'));
|
||||
setScanning(false);
|
||||
}
|
||||
}
|
||||
|
||||
function stopScan() {
|
||||
controlsRef.current?.stop();
|
||||
setScanning(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="rounded-[28px] bg-white p-4 shadow-sm">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="font-semibold">QR-вход</h2>
|
||||
<p className="text-sm text-[#667085]">Сканируйте QR-код на сайте, чтобы мгновенно подтвердить вход.</p>
|
||||
</div>
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-2xl bg-[#eef4ff] text-[#3390ec]">
|
||||
<QrCode className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-[24px] bg-[#111827]">
|
||||
<video ref={videoRef} className="aspect-[4/3] w-full object-cover" muted playsInline />
|
||||
</div>
|
||||
|
||||
{status ? (
|
||||
<div className="mt-3 flex items-center gap-2 rounded-2xl bg-[#f4f5f8] px-3 py-2 text-sm text-[#667085]">
|
||||
{status.includes('подтвержд') ? <CheckCircle2 className="h-4 w-4 text-emerald-600" /> : <XCircle className="h-4 w-4 text-[#667085]" />}
|
||||
{status}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-4 flex gap-2">
|
||||
{scanning ? (
|
||||
<Button variant="secondary" className="flex-1" onClick={stopScan}>
|
||||
Остановить
|
||||
</Button>
|
||||
) : (
|
||||
<Button className="flex-1" onClick={() => void startScan()}>
|
||||
<Camera className="h-4 w-4" />
|
||||
Сканировать QR
|
||||
</Button>
|
||||
)}
|
||||
{approving ? (
|
||||
<Button variant="secondary" size="icon" disabled>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
35
tauri_app/src/features/chat/chats-screen.tsx
Normal file
35
tauri_app/src/features/chat/chats-screen.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { openUrl } from '@tauri-apps/plugin-opener';
|
||||
import { ExternalLink, MessageCircle } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
const FRONTEND_URL = (import.meta.env.VITE_FRONTEND_URL ?? 'http://localhost:3002').replace(/\/$/, '');
|
||||
|
||||
export function ChatsScreen() {
|
||||
const familyUrl = `${FRONTEND_URL}/family`;
|
||||
|
||||
return (
|
||||
<div className="safe-top flex h-screen flex-col p-4">
|
||||
<header className="mb-4 rounded-[28px] bg-white p-5 shadow-sm">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm text-[#667085]">Messenger</p>
|
||||
<h1 className="mt-1 text-2xl font-semibold">Чаты</h1>
|
||||
<p className="mt-2 text-sm text-[#667085]">Вкладка использует web UI семьи, чтобы дизайн и поведение совпадали с сайтом.</p>
|
||||
</div>
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-3xl bg-[#20212b] text-white">
|
||||
<MessageCircle className="h-6 w-6" />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-hidden rounded-[28px] bg-white shadow-sm">
|
||||
<iframe src={familyUrl} title="Lendry ID Family Messenger" className="h-full w-full border-0" />
|
||||
</div>
|
||||
|
||||
<Button variant="secondary" className="mt-4 lg:hidden" onClick={() => void openUrl(familyUrl)}>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
Открыть мессенджер отдельно
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
116
tauri_app/src/features/security/security-screen.tsx
Normal file
116
tauri_app/src/features/security/security-screen.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Loader2, LogOut, RefreshCw, ShieldCheck, Smartphone } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type { ActiveSession } from '@/lib/api';
|
||||
import { useMobileAuth } from '~/lib/auth';
|
||||
import { fetchActiveSessions, revokeSession } from '~/lib/mobile-api';
|
||||
import { getErrorMessage } from '~/lib/errors';
|
||||
import { QrScanner } from '../auth/qr-scanner';
|
||||
|
||||
function formatDate(value: string) {
|
||||
return new Intl.DateTimeFormat('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
export function SecurityScreen() {
|
||||
const { user, token, sessionId, logout } = useMobileAuth();
|
||||
const [sessions, setSessions] = useState<ActiveSession[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [revokingId, setRevokingId] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function loadSessions() {
|
||||
if (!user || !token) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetchActiveSessions(user.id, token);
|
||||
setSessions(response.sessions ?? []);
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err, 'Не удалось загрузить сессии'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void loadSessions();
|
||||
}, [token, user?.id]);
|
||||
|
||||
async function revoke(id: string) {
|
||||
if (!user || !token) return;
|
||||
setRevokingId(id);
|
||||
try {
|
||||
await revokeSession(user.id, id, token);
|
||||
setSessions((current) => current.filter((session) => session.id !== id));
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err, 'Не удалось отозвать сессию'));
|
||||
} finally {
|
||||
setRevokingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="safe-top mx-auto max-w-3xl space-y-4 p-4">
|
||||
<header className="rounded-[28px] bg-white p-5 shadow-sm">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm text-[#667085]">SSO Authenticator</p>
|
||||
<h1 className="mt-1 text-2xl font-semibold">Безопасность</h1>
|
||||
<p className="mt-2 text-sm text-[#667085]">{user?.displayName}</p>
|
||||
</div>
|
||||
<Button variant="secondary" size="icon" onClick={logout} aria-label="Выйти">
|
||||
<LogOut className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<QrScanner />
|
||||
|
||||
<section className="rounded-[28px] bg-white p-5 shadow-sm">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="flex items-center gap-2 font-semibold">
|
||||
<ShieldCheck className="h-5 w-5" />
|
||||
Устройства и сессии
|
||||
</h2>
|
||||
<p className="text-sm text-[#667085]">Завершайте чужие или потерянные сессии удалённо.</p>
|
||||
</div>
|
||||
<Button variant="secondary" size="icon" disabled={loading} onClick={() => void loadSessions()}>
|
||||
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error ? <p className="mb-3 rounded-2xl bg-red-50 px-3 py-2 text-sm text-red-700">{error}</p> : null}
|
||||
|
||||
<div className="space-y-2">
|
||||
{sessions.map((session) => {
|
||||
const current = session.id === sessionId;
|
||||
return (
|
||||
<div key={session.id} className="flex items-center gap-3 rounded-2xl border border-[#eceef4] p-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-[#eef4ff] text-[#3390ec]">
|
||||
<Smartphone className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">{session.userAgent ?? 'Устройство'}</p>
|
||||
<p className="text-xs text-[#667085]">
|
||||
{session.status} · {formatDate(session.updatedAt)}
|
||||
{current ? ' · текущая сессия' : ''}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="secondary" size="sm" disabled={current || revokingId === session.id} onClick={() => void revoke(session.id)}>
|
||||
{revokingId === session.id ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Выйти'}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{!sessions.length && !loading ? <p className="py-4 text-center text-sm text-[#667085]">Активных сессий не найдено</p> : null}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
85
tauri_app/src/features/totp/secure-totp-store.ts
Normal file
85
tauri_app/src/features/totp/secure-totp-store.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { appDataDir } from '@tauri-apps/api/path';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import type { TotpProfile } from './totp';
|
||||
|
||||
const CLIENT_NAME = 'lendry-totp';
|
||||
const STORE_KEY = 'totp-profiles';
|
||||
const PASSWORD_KEY = 'lendry_stronghold_password';
|
||||
|
||||
function getStrongholdPassword() {
|
||||
const existing = window.localStorage.getItem(PASSWORD_KEY);
|
||||
if (existing) return existing;
|
||||
const created = crypto.randomUUID() + crypto.randomUUID();
|
||||
window.localStorage.setItem(PASSWORD_KEY, created);
|
||||
return created;
|
||||
}
|
||||
|
||||
async function openStronghold() {
|
||||
const strongholdModule = await import('@tauri-apps/plugin-stronghold');
|
||||
const Stronghold = (strongholdModule as unknown as { Stronghold?: unknown }).Stronghold as {
|
||||
load: (path: string, password: string) => Promise<unknown>;
|
||||
};
|
||||
const baseDir = await appDataDir();
|
||||
const stronghold = await Stronghold.load(`${baseDir}/totp.stronghold`, getStrongholdPassword());
|
||||
const anyStronghold = stronghold as {
|
||||
loadClient?: (name: string) => Promise<unknown>;
|
||||
createClient?: (name: string) => Promise<unknown>;
|
||||
save: () => Promise<void>;
|
||||
};
|
||||
let client: unknown;
|
||||
try {
|
||||
client = await anyStronghold.loadClient?.(CLIENT_NAME);
|
||||
} catch {
|
||||
client = await anyStronghold.createClient?.(CLIENT_NAME);
|
||||
}
|
||||
if (!client) {
|
||||
client = await anyStronghold.createClient?.(CLIENT_NAME);
|
||||
}
|
||||
const store = (client as { getStore: () => unknown }).getStore();
|
||||
return { stronghold: anyStronghold, store: store as StrongholdStore };
|
||||
}
|
||||
|
||||
interface StrongholdStore {
|
||||
get(key: string): Promise<number[] | Uint8Array | null>;
|
||||
insert(key: string, value: number[] | Uint8Array): Promise<void>;
|
||||
remove(key: string): Promise<void>;
|
||||
}
|
||||
|
||||
function encodeProfiles(profiles: TotpProfile[]) {
|
||||
return new TextEncoder().encode(JSON.stringify(profiles));
|
||||
}
|
||||
|
||||
function decodeProfiles(value: number[] | Uint8Array | null) {
|
||||
if (!value) return [];
|
||||
const bytes = value instanceof Uint8Array ? value : new Uint8Array(value);
|
||||
const text = new TextDecoder().decode(bytes);
|
||||
return JSON.parse(text) as TotpProfile[];
|
||||
}
|
||||
|
||||
export async function loadTotpProfiles() {
|
||||
const { store } = await openStronghold();
|
||||
return decodeProfiles(await store.get(STORE_KEY));
|
||||
}
|
||||
|
||||
export async function saveTotpProfiles(profiles: TotpProfile[]) {
|
||||
const { stronghold, store } = await openStronghold();
|
||||
await store.insert(STORE_KEY, encodeProfiles(profiles));
|
||||
await stronghold.save();
|
||||
}
|
||||
|
||||
export async function addTotpProfile(profile: Omit<TotpProfile, 'id' | 'createdAt'>) {
|
||||
await invoke('validate_totp_profile_input', profile);
|
||||
const profiles = await loadTotpProfiles();
|
||||
const next: TotpProfile = {
|
||||
...profile,
|
||||
id: crypto.randomUUID(),
|
||||
createdAt: new Date().toISOString()
|
||||
};
|
||||
await saveTotpProfiles([...profiles, next]);
|
||||
return next;
|
||||
}
|
||||
|
||||
export async function deleteTotpProfile(profileId: string) {
|
||||
const profiles = await loadTotpProfiles();
|
||||
await saveTotpProfiles(profiles.filter((profile) => profile.id !== profileId));
|
||||
}
|
||||
161
tauri_app/src/features/totp/totp-screen.tsx
Normal file
161
tauri_app/src/features/totp/totp-screen.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Copy, KeyRound, Loader2, Plus, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { addTotpProfile, deleteTotpProfile, loadTotpProfiles } from './secure-totp-store';
|
||||
import { generateTotpCode, getRemainingSeconds, parseOtpAuthUrl, type TotpProfile } from './totp';
|
||||
|
||||
interface TotpCodeState {
|
||||
code: string;
|
||||
remaining: number;
|
||||
}
|
||||
|
||||
export function TotpScreen() {
|
||||
const [profiles, setProfiles] = useState<TotpProfile[]>([]);
|
||||
const [codes, setCodes] = useState<Record<string, TotpCodeState>>({});
|
||||
const [issuer, setIssuer] = useState('');
|
||||
const [label, setLabel] = useState('');
|
||||
const [secret, setSecret] = useState('');
|
||||
const [otpauth, setOtpauth] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const canSave = useMemo(() => issuer.trim() && label.trim() && secret.trim(), [issuer, label, secret]);
|
||||
|
||||
useEffect(() => {
|
||||
loadTotpProfiles()
|
||||
.then(setProfiles)
|
||||
.catch((err) => setError(err instanceof Error ? err.message : 'Не удалось открыть защищённое хранилище'))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function tick() {
|
||||
const entries = await Promise.all(
|
||||
profiles.map(async (profile) => [
|
||||
profile.id,
|
||||
{
|
||||
code: await generateTotpCode(profile),
|
||||
remaining: getRemainingSeconds(profile.period)
|
||||
}
|
||||
] as const)
|
||||
);
|
||||
if (!cancelled) {
|
||||
setCodes(Object.fromEntries(entries));
|
||||
}
|
||||
}
|
||||
void tick();
|
||||
const timer = window.setInterval(() => void tick(), 1000);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [profiles]);
|
||||
|
||||
function applyOtpAuthUrl(value: string) {
|
||||
setOtpauth(value);
|
||||
const parsed = parseOtpAuthUrl(value);
|
||||
if (!parsed) return;
|
||||
setIssuer(parsed.issuer ?? '');
|
||||
setLabel(parsed.label ?? '');
|
||||
setSecret(parsed.secret ?? '');
|
||||
}
|
||||
|
||||
async function saveProfile() {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const profile = await addTotpProfile({
|
||||
issuer: issuer.trim(),
|
||||
label: label.trim(),
|
||||
secret: secret.trim(),
|
||||
digits: 6,
|
||||
period: 30
|
||||
});
|
||||
setProfiles((current) => [...current, profile]);
|
||||
setIssuer('');
|
||||
setLabel('');
|
||||
setSecret('');
|
||||
setOtpauth('');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Не удалось сохранить TOTP');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function removeProfile(profileId: string) {
|
||||
await deleteTotpProfile(profileId);
|
||||
setProfiles((current) => current.filter((profile) => profile.id !== profileId));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="safe-top mx-auto max-w-3xl space-y-4 p-4">
|
||||
<header className="rounded-[28px] bg-white p-5 shadow-sm">
|
||||
<p className="text-sm text-[#667085]">Offline 2FA</p>
|
||||
<h1 className="mt-1 text-2xl font-semibold">Authenticator</h1>
|
||||
<p className="mt-2 text-sm text-[#667085]">TOTP-секреты хранятся в Tauri Stronghold, а не в LocalStorage.</p>
|
||||
</header>
|
||||
|
||||
<section className="rounded-[28px] bg-white p-5 shadow-sm">
|
||||
<h2 className="mb-4 flex items-center gap-2 font-semibold">
|
||||
<Plus className="h-5 w-5" />
|
||||
Добавить код
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
<Input value={otpauth} onChange={(event) => applyOtpAuthUrl(event.target.value)} placeholder="otpauth://totp/..." />
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Input value={issuer} onChange={(event) => setIssuer(event.target.value)} placeholder="Сервис, например GitHub" />
|
||||
<Input value={label} onChange={(event) => setLabel(event.target.value)} placeholder="Аккаунт" />
|
||||
</div>
|
||||
<Input value={secret} onChange={(event) => setSecret(event.target.value)} placeholder="Base32 secret" />
|
||||
{error ? <p className="rounded-2xl bg-red-50 px-3 py-2 text-sm text-red-700">{error}</p> : null}
|
||||
<Button disabled={!canSave || saving} onClick={() => void saveProfile()}>
|
||||
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <KeyRound className="h-4 w-4" />}
|
||||
Сохранить в защищённом хранилище
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
{loading ? (
|
||||
<div className="rounded-[28px] bg-white p-5 text-sm text-[#667085]">Загрузка...</div>
|
||||
) : profiles.length ? (
|
||||
profiles.map((profile) => {
|
||||
const state = codes[profile.id];
|
||||
const progress = state ? (state.remaining / profile.period) * 100 : 0;
|
||||
return (
|
||||
<article key={profile.id} className="rounded-[28px] bg-white p-5 shadow-sm">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="font-semibold">{profile.issuer}</h3>
|
||||
<p className="text-sm text-[#667085]">{profile.label}</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" onClick={() => void removeProfile(profile.id)} aria-label="Удалить TOTP">
|
||||
<Trash2 className="h-4 w-4 text-red-600" />
|
||||
</Button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-4 flex w-full items-center justify-between rounded-3xl bg-[#20212b] px-5 py-4 text-left text-white"
|
||||
onClick={() => state?.code && navigator.clipboard.writeText(state.code)}
|
||||
>
|
||||
<span className="font-mono text-4xl font-semibold tracking-[0.2em]">{state?.code ?? '------'}</span>
|
||||
<Copy className="h-5 w-5 text-white/70" />
|
||||
</button>
|
||||
<div className="mt-3 h-2 overflow-hidden rounded-full bg-[#eef1f6]">
|
||||
<div className="h-full rounded-full bg-[#3390ec] transition-all" style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
<p className="mt-2 text-right text-xs text-[#667085]">{state?.remaining ?? profile.period} сек.</p>
|
||||
</article>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="rounded-[28px] bg-white p-8 text-center text-sm text-[#667085] shadow-sm">Добавьте первый TOTP-код</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
81
tauri_app/src/features/totp/totp.ts
Normal file
81
tauri_app/src/features/totp/totp.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
export interface TotpProfile {
|
||||
id: string;
|
||||
issuer: string;
|
||||
label: string;
|
||||
secret: string;
|
||||
digits: 6 | 8;
|
||||
period: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||
|
||||
export function normalizeBase32Secret(secret: string) {
|
||||
return secret.replace(/[\s-]/g, '').toUpperCase();
|
||||
}
|
||||
|
||||
export function parseOtpAuthUrl(value: string): Partial<TotpProfile> | null {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
if (url.protocol !== 'otpauth:' || url.hostname !== 'totp') return null;
|
||||
const labelRaw = decodeURIComponent(url.pathname.replace(/^\//, ''));
|
||||
const [issuerFromLabel, accountFromLabel] = labelRaw.includes(':') ? labelRaw.split(/:(.*)/s) : ['', labelRaw];
|
||||
const secret = url.searchParams.get('secret');
|
||||
if (!secret) return null;
|
||||
const digits = Number(url.searchParams.get('digits') ?? 6);
|
||||
const period = Number(url.searchParams.get('period') ?? 30);
|
||||
return {
|
||||
issuer: url.searchParams.get('issuer') ?? issuerFromLabel ?? '',
|
||||
label: accountFromLabel || labelRaw,
|
||||
secret: normalizeBase32Secret(secret),
|
||||
digits: digits === 8 ? 8 : 6,
|
||||
period: Number.isFinite(period) && period > 0 ? period : 30
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function decodeBase32(secret: string) {
|
||||
const normalized = normalizeBase32Secret(secret).replace(/=+$/g, '');
|
||||
let bits = '';
|
||||
for (const char of normalized) {
|
||||
const value = BASE32_ALPHABET.indexOf(char);
|
||||
if (value === -1) {
|
||||
throw new Error('Некорректный base32 secret');
|
||||
}
|
||||
bits += value.toString(2).padStart(5, '0');
|
||||
}
|
||||
|
||||
const bytes: number[] = [];
|
||||
for (let index = 0; index + 8 <= bits.length; index += 8) {
|
||||
bytes.push(Number.parseInt(bits.slice(index, index + 8), 2));
|
||||
}
|
||||
return new Uint8Array(bytes);
|
||||
}
|
||||
|
||||
function counterToBytes(counter: number) {
|
||||
const bytes = new ArrayBuffer(8);
|
||||
const view = new DataView(bytes);
|
||||
view.setUint32(4, counter, false);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
export async function generateTotpCode(profile: Pick<TotpProfile, 'secret' | 'digits' | 'period'>, now = Date.now()) {
|
||||
const keyData = decodeBase32(profile.secret);
|
||||
const key = await crypto.subtle.importKey('raw', keyData, { name: 'HMAC', hash: 'SHA-1' }, false, ['sign']);
|
||||
const counter = Math.floor(now / 1000 / profile.period);
|
||||
const signature = new Uint8Array(await crypto.subtle.sign('HMAC', key, counterToBytes(counter)));
|
||||
const offset = signature[signature.length - 1]! & 0x0f;
|
||||
const binary =
|
||||
((signature[offset]! & 0x7f) << 24) |
|
||||
((signature[offset + 1]! & 0xff) << 16) |
|
||||
((signature[offset + 2]! & 0xff) << 8) |
|
||||
(signature[offset + 3]! & 0xff);
|
||||
const mod = 10 ** profile.digits;
|
||||
return String(binary % mod).padStart(profile.digits, '0');
|
||||
}
|
||||
|
||||
export function getRemainingSeconds(period: number, now = Date.now()) {
|
||||
return period - (Math.floor(now / 1000) % period);
|
||||
}
|
||||
75
tauri_app/src/lib/auth.tsx
Normal file
75
tauri_app/src/lib/auth.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from 'react';
|
||||
import type { AuthTokens, PublicUser } from '@/lib/api';
|
||||
import {
|
||||
clearMobileAuth,
|
||||
loginWithPassword,
|
||||
persistMobileAuth,
|
||||
readStoredMobileAuth
|
||||
} from './mobile-api';
|
||||
|
||||
interface MobileAuthContextValue {
|
||||
user: PublicUser | null;
|
||||
token: string | null;
|
||||
sessionId: string | null;
|
||||
isAuthenticated: boolean;
|
||||
login: (login: string, password: string) => Promise<void>;
|
||||
applyAuth: (auth: AuthTokens) => void;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
const MobileAuthContext = createContext<MobileAuthContextValue | null>(null);
|
||||
|
||||
export function MobileAuthProvider({ children }: { children: ReactNode }) {
|
||||
const stored = typeof window === 'undefined' ? { token: null, sessionId: null, user: null } : readStoredMobileAuth();
|
||||
const [token, setToken] = useState<string | null>(stored.token);
|
||||
const [sessionId, setSessionId] = useState<string | null>(stored.sessionId);
|
||||
const [user, setUser] = useState<PublicUser | null>(stored.user);
|
||||
|
||||
const applyAuth = useCallback((auth: AuthTokens) => {
|
||||
persistMobileAuth(auth);
|
||||
setToken(auth.accessToken);
|
||||
setSessionId(auth.sessionId);
|
||||
setUser(auth.user);
|
||||
}, []);
|
||||
|
||||
const login = useCallback(
|
||||
async (loginValue: string, password: string) => {
|
||||
const auth = await loginWithPassword(loginValue, password);
|
||||
if ('requiresTotp' in auth) {
|
||||
throw new Error('Для этого аккаунта требуется TOTP-код');
|
||||
}
|
||||
applyAuth(auth);
|
||||
},
|
||||
[applyAuth]
|
||||
);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
clearMobileAuth();
|
||||
setToken(null);
|
||||
setSessionId(null);
|
||||
setUser(null);
|
||||
}, []);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
user,
|
||||
token,
|
||||
sessionId,
|
||||
isAuthenticated: Boolean(user && token),
|
||||
login,
|
||||
applyAuth,
|
||||
logout
|
||||
}),
|
||||
[applyAuth, login, logout, sessionId, token, user]
|
||||
);
|
||||
|
||||
return <MobileAuthContext.Provider value={value}>{children}</MobileAuthContext.Provider>;
|
||||
}
|
||||
|
||||
export function useMobileAuth() {
|
||||
const value = useContext(MobileAuthContext);
|
||||
if (!value) {
|
||||
throw new Error('useMobileAuth должен использоваться внутри MobileAuthProvider');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
6
tauri_app/src/lib/errors.ts
Normal file
6
tauri_app/src/lib/errors.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export function getErrorMessage(error: unknown, fallback: string) {
|
||||
if (error instanceof Error && error.message.trim()) {
|
||||
return error.message;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
138
tauri_app/src/lib/mobile-api.ts
Normal file
138
tauri_app/src/lib/mobile-api.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import type { ActiveSession, AuthTokens, PublicUser } from '@/lib/api';
|
||||
|
||||
const DEFAULT_API_URL = 'http://localhost:3000';
|
||||
|
||||
export const MOBILE_AUTH_TOKEN_KEY = 'lendry_mobile_access_token';
|
||||
export const MOBILE_REFRESH_TOKEN_KEY = 'lendry_mobile_refresh_token';
|
||||
export const MOBILE_SESSION_ID_KEY = 'lendry_mobile_session_id';
|
||||
export const MOBILE_USER_KEY = 'lendry_mobile_user';
|
||||
export const MOBILE_API_URL_KEY = 'lendry_mobile_api_url';
|
||||
|
||||
export class MobileApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status: number,
|
||||
readonly code?: string
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'MobileApiError';
|
||||
}
|
||||
}
|
||||
|
||||
export function getMobileApiUrl() {
|
||||
return (window.localStorage.getItem(MOBILE_API_URL_KEY) ?? import.meta.env.VITE_API_URL ?? DEFAULT_API_URL).replace(/\/$/, '');
|
||||
}
|
||||
|
||||
export function setMobileApiUrl(url: string) {
|
||||
window.localStorage.setItem(MOBILE_API_URL_KEY, url.replace(/\/$/, ''));
|
||||
}
|
||||
|
||||
export function readStoredMobileAuth() {
|
||||
const token = window.localStorage.getItem(MOBILE_AUTH_TOKEN_KEY);
|
||||
const refreshToken = window.localStorage.getItem(MOBILE_REFRESH_TOKEN_KEY);
|
||||
const sessionId = window.localStorage.getItem(MOBILE_SESSION_ID_KEY);
|
||||
const rawUser = window.localStorage.getItem(MOBILE_USER_KEY);
|
||||
let user: PublicUser | null = null;
|
||||
if (rawUser) {
|
||||
try {
|
||||
user = JSON.parse(rawUser) as PublicUser;
|
||||
} catch {
|
||||
user = null;
|
||||
}
|
||||
}
|
||||
return { token, refreshToken, sessionId, user };
|
||||
}
|
||||
|
||||
export function persistMobileAuth(auth: AuthTokens) {
|
||||
window.localStorage.setItem(MOBILE_AUTH_TOKEN_KEY, auth.accessToken);
|
||||
window.localStorage.setItem(MOBILE_REFRESH_TOKEN_KEY, auth.refreshToken);
|
||||
window.localStorage.setItem(MOBILE_SESSION_ID_KEY, auth.sessionId);
|
||||
window.localStorage.setItem(MOBILE_USER_KEY, JSON.stringify(auth.user));
|
||||
}
|
||||
|
||||
export function clearMobileAuth() {
|
||||
window.localStorage.removeItem(MOBILE_AUTH_TOKEN_KEY);
|
||||
window.localStorage.removeItem(MOBILE_REFRESH_TOKEN_KEY);
|
||||
window.localStorage.removeItem(MOBILE_SESSION_ID_KEY);
|
||||
window.localStorage.removeItem(MOBILE_USER_KEY);
|
||||
}
|
||||
|
||||
async function parseError(response: Response) {
|
||||
try {
|
||||
const body = (await response.json()) as { message?: string | string[]; error?: string; code?: string };
|
||||
const message = Array.isArray(body.message) ? body.message.join('\n') : body.message ?? body.error ?? 'Ошибка запроса';
|
||||
return new MobileApiError(message, response.status, body.code);
|
||||
} catch {
|
||||
return new MobileApiError('Ошибка запроса', response.status);
|
||||
}
|
||||
}
|
||||
|
||||
export async function mobileFetch<T>(path: string, options: RequestInit = {}, token?: string | null): Promise<T> {
|
||||
const response = await fetch(`${getMobileApiUrl()}${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...options.headers
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw await parseError(response);
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export interface TotpChallengeResponse {
|
||||
totpChallengeToken: string;
|
||||
}
|
||||
|
||||
export type PasswordLoginResponse = AuthTokens | {
|
||||
requiresTotp: true;
|
||||
totpChallengeToken: string;
|
||||
};
|
||||
|
||||
export async function loginWithPassword(login: string, password: string) {
|
||||
return mobileFetch<PasswordLoginResponse>('/auth/login/password', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
login,
|
||||
password,
|
||||
fingerprint: crypto.randomUUID(),
|
||||
deviceName: 'Lendry ID Mobile',
|
||||
deviceType: 'MOBILE'
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
export async function beginTotpLogin(recipient: string) {
|
||||
return mobileFetch<TotpChallengeResponse>('/auth/totp/begin', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
recipient,
|
||||
fingerprint: crypto.randomUUID(),
|
||||
deviceName: 'Lendry ID Mobile',
|
||||
deviceType: 'MOBILE'
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
export async function verifyTotpLogin(totpChallengeToken: string, code: string) {
|
||||
return mobileFetch<AuthTokens>('/auth/totp/verify', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ totpChallengeToken, code })
|
||||
});
|
||||
}
|
||||
|
||||
export async function approveQrLogin(sessionId: string, token: string) {
|
||||
return mobileFetch(`/auth/advanced/qr/session/${encodeURIComponent(sessionId)}/approve`, { method: 'POST' }, token);
|
||||
}
|
||||
|
||||
export async function fetchActiveSessions(userId: string, token: string) {
|
||||
return mobileFetch<{ sessions?: ActiveSession[] }>(`/security/users/${encodeURIComponent(userId)}/sessions`, {}, token);
|
||||
}
|
||||
|
||||
export async function revokeSession(userId: string, sessionId: string, token: string) {
|
||||
return mobileFetch(`/security/users/${encodeURIComponent(userId)}/sessions/${encodeURIComponent(sessionId)}/revoke`, { method: 'POST' }, token);
|
||||
}
|
||||
10
tauri_app/src/main.tsx
Normal file
10
tauri_app/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { App } from './App';
|
||||
import './styles.css';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
21
tauri_app/src/styles.css
Normal file
21
tauri_app/src/styles.css
Normal file
@@ -0,0 +1,21 @@
|
||||
@import "tailwindcss";
|
||||
@import "../../apps/frontend/app/globals.css";
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
overscroll-behavior: none;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.safe-bottom {
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
.safe-top {
|
||||
padding-top: env(safe-area-inset-top);
|
||||
}
|
||||
25
tauri_app/tsconfig.json
Normal file
25
tauri_app/tsconfig.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"extends": "../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["DOM", "DOM.Iterable", "ES2022"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["../apps/frontend/*"],
|
||||
"~/*": ["./src/*"]
|
||||
},
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
32
tauri_app/vite.config.ts
Normal file
32
tauri_app/vite.config.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
const rootDir = fileURLToPath(new URL('.', import.meta.url));
|
||||
const frontendDir = path.resolve(rootDir, '../apps/frontend');
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
clearScreen: false,
|
||||
server: {
|
||||
port: 1420,
|
||||
strictPort: true,
|
||||
watch: {
|
||||
ignored: ['**/src-tauri/**']
|
||||
}
|
||||
},
|
||||
envPrefix: ['VITE_', 'TAURI_'],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': frontendDir,
|
||||
'~': path.resolve(rootDir, 'src')
|
||||
}
|
||||
},
|
||||
build: {
|
||||
target: ['es2022', 'chrome110', 'safari15'],
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user