feat: app mobile React Native / Expo (v0.31.0)
Package mobile/ (Expo SDK 56, expo-router) réutilisant l'API backend : Login (mdp+TOTP), Chat (+ TTS expo-speech), Review (approuver/refuser). JWT en expo-secure-store, thème dark HUD, EXPO_PUBLIC_API_BASE. typecheck vert. STT mobile reporté (lib native), TTS OK. Versions gérées par Expo. Palier de risque : reversible (client mobile, API commune). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,14 @@ incompatibles. Chaque ligne renvoie à un commit dédié (un artefact = un commi
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.31.0] — 2026-06-23 — app mobile (React Native / Expo)
|
||||||
|
### Added
|
||||||
|
- Package **`mobile/`** : app Expo SDK 56 (expo-router) réutilisant l'API backend.
|
||||||
|
Écrans Login (mdp + TOTP), Chat (+ TTS expo-speech), Review (approuver/refuser).
|
||||||
|
JWT en `expo-secure-store`, thème dark HUD, `EXPO_PUBLIC_API_BASE` configurable.
|
||||||
|
`tsc --noEmit` vert. STT mobile reporté (lib native) ; TTS OK.
|
||||||
|
- README racine : entrées `web/` et `mobile/`.
|
||||||
|
|
||||||
## [0.30.0] — 2026-06-23 — polish UI
|
## [0.30.0] — 2026-06-23 — polish UI
|
||||||
### Added
|
### Added
|
||||||
- Icônes **Lucide** (SVG) partout (chat, voix, review, nav) — fini le texte/emoji.
|
- Icônes **Lucide** (SVG) partout (chat, voix, review, nav) — fini le texte/emoji.
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ Ollama **cloud**.
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `docs/` | Architecture, sécurité, versioning, paliers de risque, gabarit d'asset |
|
| `docs/` | Architecture, sécurité, versioning, paliers de risque, gabarit d'asset |
|
||||||
| `infra/` | docker-compose de la stack + socket-proxy + notes réseau |
|
| `infra/` | docker-compose de la stack + socket-proxy + notes réseau |
|
||||||
| `orchestrator/` | Le cerveau (TypeScript/Node, Fastify) |
|
| `orchestrator/` | Le cerveau (TypeScript/Node, Fastify) + API |
|
||||||
|
| `web/` | UI web/PWA (React/Vite/Tailwind) — chat, review, voix |
|
||||||
|
| `mobile/` | App mobile (React Native / Expo) — chat, review |
|
||||||
| `workflows-n8n/` | Exports JSON des workflows (le dépôt fait foi) |
|
| `workflows-n8n/` | Exports JSON des workflows (le dépôt fait foi) |
|
||||||
|
|
||||||
## Démarrage (dev)
|
## Démarrage (dev)
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
node_modules/
|
||||||
|
.expo/
|
||||||
|
dist/
|
||||||
|
web-build/
|
||||||
|
*.log
|
||||||
|
# Builds natifs générés (prebuild)
|
||||||
|
/ios
|
||||||
|
/android
|
||||||
|
.env*.local
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# mobile — app CHLOVA (React Native / Expo)
|
||||||
|
|
||||||
|
Client natif iOS/Android, **réutilise l'API du backend** (mêmes endpoints que le
|
||||||
|
web). Expo SDK 56 + expo-router. Thème dark HUD (voir `../docs/ui-design.md`).
|
||||||
|
|
||||||
|
## Lancer
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
# URL du backend (HTTPS, derrière Traefik) :
|
||||||
|
export EXPO_PUBLIC_API_BASE=https://chlova.example.com # PowerShell: $env:EXPO_PUBLIC_API_BASE=...
|
||||||
|
npm start # puis 'a' (Android) / 'i' (iOS) / QR Expo Go
|
||||||
|
npm run typecheck
|
||||||
|
```
|
||||||
|
|
||||||
|
## Écrans
|
||||||
|
| Fichier | Rôle |
|
||||||
|
|---|---|
|
||||||
|
| `app/login.tsx` | Login fort (mot de passe + TOTP). |
|
||||||
|
| `app/(tabs)/chat.tsx` | Conversation agent + TTS (expo-speech). |
|
||||||
|
| `app/(tabs)/review.tsx` | Need-review : approuver / refuser (confirm natif). |
|
||||||
|
| `src/api.ts` | Client API (base = `EXPO_PUBLIC_API_BASE`). |
|
||||||
|
| `src/auth.tsx` | JWT en `expo-secure-store` (Keychain/Keystore). |
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- **STT** (dictée) non inclus en v1 (nécessite une lib native dédiée) ; **TTS** OK
|
||||||
|
via `expo-speech`. La voix complète existe déjà côté web.
|
||||||
|
- Versions des libs gérées par **Expo** (`npx expo install`), pas en épinglage
|
||||||
|
exact (norme de l'écosystème RN/Expo).
|
||||||
|
- `npm audit` : ~10 alertes modérées dans la **chaîne de build Expo CLI**
|
||||||
|
(`xcode`/`uuid`, prebuild dev-time) — pas dans le bundle livré ; non corrigeables
|
||||||
|
sans casser le SDK.
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"expo": {
|
||||||
|
"name": "CHLOVA",
|
||||||
|
"slug": "chlova",
|
||||||
|
"scheme": "chlova",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"orientation": "portrait",
|
||||||
|
"userInterfaceStyle": "dark",
|
||||||
|
"backgroundColor": "#020617",
|
||||||
|
"plugins": [
|
||||||
|
"expo-router",
|
||||||
|
"expo-secure-store",
|
||||||
|
"expo-status-bar"
|
||||||
|
],
|
||||||
|
"experiments": {
|
||||||
|
"typedRoutes": false
|
||||||
|
},
|
||||||
|
"extra": {
|
||||||
|
"router": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { Tabs, Redirect } from "expo-router";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { useAuth } from "@/auth";
|
||||||
|
import { C } from "@/theme";
|
||||||
|
|
||||||
|
export default function TabsLayout() {
|
||||||
|
const { token, ready } = useAuth();
|
||||||
|
if (ready && !token) return <Redirect href="/login" />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tabs
|
||||||
|
screenOptions={{
|
||||||
|
headerStyle: { backgroundColor: C.surface },
|
||||||
|
headerTintColor: C.accent,
|
||||||
|
tabBarStyle: { backgroundColor: C.surface, borderTopColor: C.border },
|
||||||
|
tabBarActiveTintColor: C.accent,
|
||||||
|
tabBarInactiveTintColor: C.muted,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="chat"
|
||||||
|
options={{
|
||||||
|
title: "Chat",
|
||||||
|
tabBarIcon: ({ color, size }) => <Ionicons name="chatbubble-ellipses" color={color} size={size} />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="review"
|
||||||
|
options={{
|
||||||
|
title: "Review",
|
||||||
|
tabBarIcon: ({ color, size }) => <Ionicons name="shield-checkmark" color={color} size={size} />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tabs>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { View, Text, TextInput, Pressable, ScrollView, StyleSheet } from "react-native";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import * as Speech from "expo-speech";
|
||||||
|
import { useAuth } from "@/auth";
|
||||||
|
import { api, ApiError } from "@/api";
|
||||||
|
import { C } from "@/theme";
|
||||||
|
|
||||||
|
interface Msg {
|
||||||
|
role: "user" | "assistant";
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Chat() {
|
||||||
|
const { token, logout } = useAuth();
|
||||||
|
const [messages, setMessages] = useState<Msg[]>([]);
|
||||||
|
const [input, setInput] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [speak, setSpeak] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const scroll = useRef<ScrollView>(null);
|
||||||
|
|
||||||
|
const send = async (): Promise<void> => {
|
||||||
|
const t = input.trim();
|
||||||
|
if (!t || busy || !token) return;
|
||||||
|
setInput("");
|
||||||
|
setError(null);
|
||||||
|
setMessages((m) => [...m, { role: "user", text: t }]);
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const { reply } = await api.chat(token, t);
|
||||||
|
setMessages((m) => [...m, { role: "assistant", text: reply }]);
|
||||||
|
if (speak) Speech.speak(reply, { language: "fr-FR" });
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiError && err.status === 401) {
|
||||||
|
await logout();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(err instanceof Error ? err.message : "Erreur");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={s.wrap}>
|
||||||
|
<ScrollView
|
||||||
|
ref={scroll}
|
||||||
|
style={s.list}
|
||||||
|
contentContainerStyle={{ padding: 12, gap: 8 }}
|
||||||
|
onContentSizeChange={() => scroll.current?.scrollToEnd({ animated: true })}
|
||||||
|
>
|
||||||
|
{messages.length === 0 && <Text style={s.muted}>Pose une question à CHLOVA…</Text>}
|
||||||
|
{messages.map((m, i) => (
|
||||||
|
<View key={i} style={[s.bubble, m.role === "user" ? s.user : s.assistant]}>
|
||||||
|
<Text style={s.bubbleText}>{m.text}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
{busy && <Text style={s.muted}>CHLOVA réfléchit…</Text>}
|
||||||
|
{error && <Text style={s.error}>{error}</Text>}
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
<View style={s.bar}>
|
||||||
|
<Pressable onPress={() => setSpeak((v) => !v)} style={s.iconBtn} accessibilityLabel="Voix">
|
||||||
|
<Ionicons name={speak ? "volume-high" : "volume-mute"} size={20} color={speak ? C.accent : C.muted} />
|
||||||
|
</Pressable>
|
||||||
|
<TextInput
|
||||||
|
style={s.input}
|
||||||
|
placeholder="Message…"
|
||||||
|
placeholderTextColor={C.muted}
|
||||||
|
value={input}
|
||||||
|
onChangeText={setInput}
|
||||||
|
editable={!busy}
|
||||||
|
/>
|
||||||
|
<Pressable onPress={send} disabled={busy || !input.trim()} style={[s.send, (busy || !input.trim()) && s.disabled]}>
|
||||||
|
<Ionicons name="send" size={18} color={C.bg} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const s = StyleSheet.create({
|
||||||
|
wrap: { flex: 1, backgroundColor: C.bg },
|
||||||
|
list: { flex: 1 },
|
||||||
|
muted: { color: C.muted, fontSize: 13 },
|
||||||
|
error: { color: C.danger, fontSize: 13 },
|
||||||
|
bubble: { maxWidth: "85%", borderRadius: 10, padding: 10, borderWidth: 1 },
|
||||||
|
user: { alignSelf: "flex-end", backgroundColor: C.surface2, borderColor: C.accent },
|
||||||
|
assistant: { alignSelf: "flex-start", backgroundColor: C.surface, borderColor: C.border },
|
||||||
|
bubbleText: { color: C.fg, fontSize: 14 },
|
||||||
|
bar: { flexDirection: "row", alignItems: "center", gap: 8, padding: 10, borderTopWidth: 1, borderTopColor: C.border, backgroundColor: C.surface },
|
||||||
|
iconBtn: { padding: 8, borderRadius: 8, borderWidth: 1, borderColor: C.border },
|
||||||
|
input: { flex: 1, backgroundColor: C.surface2, borderColor: C.border, borderWidth: 1, borderRadius: 8, color: C.fg, paddingHorizontal: 12, paddingVertical: 8 },
|
||||||
|
send: { backgroundColor: C.accent, borderRadius: 8, padding: 10 },
|
||||||
|
disabled: { opacity: 0.5 },
|
||||||
|
});
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { View, Text, Pressable, FlatList, StyleSheet, Alert } from "react-native";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { useAuth } from "@/auth";
|
||||||
|
import { api, ApiError, type Asset } from "@/api";
|
||||||
|
import { C } from "@/theme";
|
||||||
|
|
||||||
|
export default function Review() {
|
||||||
|
const { token, logout } = useAuth();
|
||||||
|
const [assets, setAssets] = useState<Asset[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const onErr = useCallback(
|
||||||
|
(err: unknown): void => {
|
||||||
|
if (err instanceof ApiError && err.status === 401) void logout();
|
||||||
|
else setError(err instanceof Error ? err.message : "Erreur");
|
||||||
|
},
|
||||||
|
[logout],
|
||||||
|
);
|
||||||
|
|
||||||
|
const refresh = useCallback(async (): Promise<void> => {
|
||||||
|
if (!token) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const { assets } = await api.review(token);
|
||||||
|
setAssets(assets);
|
||||||
|
setError(null);
|
||||||
|
} catch (err) {
|
||||||
|
onErr(err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [token, onErr]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refresh();
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
const decide = async (id: string, action: "approve" | "refuse"): Promise<void> => {
|
||||||
|
if (!token) return;
|
||||||
|
try {
|
||||||
|
if (action === "approve") await api.approve(token, id);
|
||||||
|
else await api.refuse(token, id);
|
||||||
|
await refresh();
|
||||||
|
} catch (err) {
|
||||||
|
onErr(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmRefuse = (id: string): void => {
|
||||||
|
Alert.alert("Refuser", `Refuser définitivement ${id} ?`, [
|
||||||
|
{ text: "Annuler", style: "cancel" },
|
||||||
|
{ text: "Refuser", style: "destructive", onPress: () => void decide(id, "refuse") },
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={s.wrap}>
|
||||||
|
<FlatList
|
||||||
|
data={assets}
|
||||||
|
keyExtractor={(a) => a.id}
|
||||||
|
contentContainerStyle={{ padding: 12, gap: 8 }}
|
||||||
|
onRefresh={() => void refresh()}
|
||||||
|
refreshing={loading}
|
||||||
|
ListEmptyComponent={!loading ? <Text style={s.muted}>Aucun asset en attente.</Text> : null}
|
||||||
|
ListHeaderComponent={error ? <Text style={s.error}>{error}</Text> : null}
|
||||||
|
renderItem={({ item: a }) => (
|
||||||
|
<View style={s.card}>
|
||||||
|
<Text style={s.id}>{a.id}</Text>
|
||||||
|
<Text style={[s.tier, { color: a.riskTier === "privileged" ? C.danger : C.success }]}>
|
||||||
|
{a.riskTier} · {a.status} · v{a.version}
|
||||||
|
</Text>
|
||||||
|
<View style={s.actions}>
|
||||||
|
<Pressable style={[s.act, s.approve]} onPress={() => void decide(a.id, "approve")}>
|
||||||
|
<Ionicons name="checkmark" size={16} color={C.success} />
|
||||||
|
<Text style={[s.actText, { color: C.success }]}>Approuver</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable style={[s.act, s.refuse]} onPress={() => confirmRefuse(a.id)}>
|
||||||
|
<Ionicons name="close" size={16} color={C.danger} />
|
||||||
|
<Text style={[s.actText, { color: C.danger }]}>Refuser</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Pressable style={s.logout} onPress={() => void logout()}>
|
||||||
|
<Ionicons name="log-out-outline" size={18} color={C.muted} />
|
||||||
|
<Text style={s.muted}> Déconnexion</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const s = StyleSheet.create({
|
||||||
|
wrap: { flex: 1, backgroundColor: C.bg },
|
||||||
|
muted: { color: C.muted, fontSize: 13 },
|
||||||
|
error: { color: C.danger, fontSize: 13, marginBottom: 8 },
|
||||||
|
card: { backgroundColor: C.surface, borderColor: C.border, borderWidth: 1, borderRadius: 10, padding: 12, gap: 6 },
|
||||||
|
id: { color: C.fg, fontFamily: "monospace", fontSize: 13 },
|
||||||
|
tier: { fontSize: 12 },
|
||||||
|
actions: { flexDirection: "row", gap: 8, marginTop: 4 },
|
||||||
|
act: { flexDirection: "row", alignItems: "center", gap: 4, borderWidth: 1, borderRadius: 8, paddingHorizontal: 10, paddingVertical: 6 },
|
||||||
|
approve: { borderColor: C.success },
|
||||||
|
refuse: { borderColor: C.danger },
|
||||||
|
actText: { fontSize: 13 },
|
||||||
|
logout: { flexDirection: "row", alignItems: "center", justifyContent: "center", padding: 12, borderTopWidth: 1, borderTopColor: C.border },
|
||||||
|
});
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { Stack } from "expo-router";
|
||||||
|
import { StatusBar } from "expo-status-bar";
|
||||||
|
import { AuthProvider } from "@/auth";
|
||||||
|
import { C } from "@/theme";
|
||||||
|
|
||||||
|
export default function RootLayout() {
|
||||||
|
return (
|
||||||
|
<AuthProvider>
|
||||||
|
<StatusBar style="light" />
|
||||||
|
<Stack
|
||||||
|
screenOptions={{
|
||||||
|
headerShown: false,
|
||||||
|
contentStyle: { backgroundColor: C.bg },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</AuthProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { Redirect } from "expo-router";
|
||||||
|
import { ActivityIndicator, View } from "react-native";
|
||||||
|
import { useAuth } from "@/auth";
|
||||||
|
import { C } from "@/theme";
|
||||||
|
|
||||||
|
export default function Index() {
|
||||||
|
const { token, ready } = useAuth();
|
||||||
|
if (!ready) {
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1, justifyContent: "center", backgroundColor: C.bg }}>
|
||||||
|
<ActivityIndicator color={C.accent} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <Redirect href={token ? "/(tabs)/chat" : "/login"} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { View, Text, TextInput, Pressable, StyleSheet } from "react-native";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
import { useAuth } from "@/auth";
|
||||||
|
import { ApiError } from "@/api";
|
||||||
|
import { C } from "@/theme";
|
||||||
|
|
||||||
|
export default function Login() {
|
||||||
|
const { login } = useAuth();
|
||||||
|
const router = useRouter();
|
||||||
|
const [user, setUser] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [totp, setTotp] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const submit = async (): Promise<void> => {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await login(user, password, totp);
|
||||||
|
router.replace("/(tabs)/chat");
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError && err.status === 429 ? "Trop de tentatives." : "Identifiants invalides.");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={s.wrap}>
|
||||||
|
<Text style={s.title}>CHLOVA</Text>
|
||||||
|
<Text style={s.sub}>Accès propriétaire — authentification forte.</Text>
|
||||||
|
<TextInput style={s.input} placeholder="Utilisateur" placeholderTextColor={C.muted} autoCapitalize="none" value={user} onChangeText={setUser} />
|
||||||
|
<TextInput style={s.input} placeholder="Mot de passe" placeholderTextColor={C.muted} secureTextEntry value={password} onChangeText={setPassword} />
|
||||||
|
<TextInput style={s.input} placeholder="Code 2FA (TOTP)" placeholderTextColor={C.muted} keyboardType="number-pad" value={totp} onChangeText={setTotp} />
|
||||||
|
{error && <Text style={s.error}>{error}</Text>}
|
||||||
|
<Pressable style={[s.btn, busy && s.btnDisabled]} onPress={submit} disabled={busy}>
|
||||||
|
<Text style={s.btnText}>{busy ? "Connexion…" : "Se connecter"}</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const s = StyleSheet.create({
|
||||||
|
wrap: { flex: 1, justifyContent: "center", padding: 24, gap: 12, backgroundColor: C.bg },
|
||||||
|
title: { color: C.accent, fontSize: 32, fontWeight: "700", letterSpacing: 2 },
|
||||||
|
sub: { color: C.muted, marginBottom: 8 },
|
||||||
|
input: { backgroundColor: C.surface2, borderColor: C.border, borderWidth: 1, borderRadius: 8, color: C.fg, padding: 12 },
|
||||||
|
error: { color: C.danger },
|
||||||
|
btn: { backgroundColor: C.accent, borderRadius: 8, padding: 14, alignItems: "center", marginTop: 4 },
|
||||||
|
btnDisabled: { opacity: 0.5 },
|
||||||
|
btnText: { color: C.bg, fontWeight: "600" },
|
||||||
|
});
|
||||||
Vendored
+4
@@ -0,0 +1,4 @@
|
|||||||
|
/// <reference types="expo/types" />
|
||||||
|
|
||||||
|
// NOTE: Ce fichier ne doit pas être édité et ne doit pas être versionné dans git
|
||||||
|
// normalement, mais on le garde ici pour permettre le typecheck hors `expo start`.
|
||||||
Generated
+7735
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"name": "chlova-mobile",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"main": "expo-router/entry",
|
||||||
|
"description": "CHLOVA — app mobile (React Native / Expo), client du backend",
|
||||||
|
"scripts": {
|
||||||
|
"start": "expo start",
|
||||||
|
"android": "expo start --android",
|
||||||
|
"ios": "expo start --ios",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@expo/vector-icons": "^15.0.2",
|
||||||
|
"expo": "56.0.12",
|
||||||
|
"expo-constants": "~56.0.18",
|
||||||
|
"expo-linking": "~56.0.14",
|
||||||
|
"expo-router": "~56.2.11",
|
||||||
|
"expo-secure-store": "~56.0.4",
|
||||||
|
"expo-speech": "~56.0.3",
|
||||||
|
"expo-status-bar": "~56.0.4",
|
||||||
|
"react": "19.2.7",
|
||||||
|
"react-native": "0.86.0",
|
||||||
|
"react-native-safe-area-context": "~5.7.0",
|
||||||
|
"react-native-screens": "4.25.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "19.2.17",
|
||||||
|
"typescript": "5.7.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
// Client de l'API CHLOVA (mobile). Le backend est distant : l'URL de base est
|
||||||
|
// fournie par EXPO_PUBLIC_API_BASE (ex. https://chlova.example.com).
|
||||||
|
|
||||||
|
const BASE = process.env.EXPO_PUBLIC_API_BASE ?? "";
|
||||||
|
|
||||||
|
export interface Asset {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
version: string;
|
||||||
|
riskTier: string;
|
||||||
|
status: string;
|
||||||
|
createdAt: number;
|
||||||
|
expiresAt: number | null;
|
||||||
|
execCount: number;
|
||||||
|
commitLink: string | null;
|
||||||
|
docLink: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(
|
||||||
|
public status: number,
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function req<T>(path: string, token: string | null, init?: RequestInit): Promise<T> {
|
||||||
|
const headers: Record<string, string> = { "content-type": "application/json" };
|
||||||
|
if (token) headers.authorization = `Bearer ${token}`;
|
||||||
|
const res = await fetch(`${BASE}/api${path}`, {
|
||||||
|
...init,
|
||||||
|
headers: { ...headers, ...(init?.headers as Record<string, string>) },
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = (await res.json().catch(() => ({}))) as { error?: string };
|
||||||
|
throw new ApiError(res.status, body.error ?? `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
return (await res.json()) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
login: (user: string, password: string, totp: string) =>
|
||||||
|
req<{ token: string }>("/auth/login", null, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ user, password, totp }),
|
||||||
|
}),
|
||||||
|
chat: (token: string, message: string) =>
|
||||||
|
req<{ reply: string }>("/chat", token, { method: "POST", body: JSON.stringify({ message }) }),
|
||||||
|
review: (token: string) => req<{ assets: Asset[] }>("/review", token),
|
||||||
|
approve: (token: string, id: string) =>
|
||||||
|
req<{ asset: Asset }>(`/review/${encodeURIComponent(id)}/approve`, token, { method: "POST" }),
|
||||||
|
refuse: (token: string, id: string) =>
|
||||||
|
req<{ asset: Asset }>(`/review/${encodeURIComponent(id)}/refuse`, token, { method: "POST" }),
|
||||||
|
state: (token: string) => req<{ phase: string; tools: number }>("/state", token),
|
||||||
|
};
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
|
||||||
|
import * as SecureStore from "expo-secure-store";
|
||||||
|
import { api } from "./api";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auth mobile : JWT stocké de façon sécurisée (Keychain/Keystore via
|
||||||
|
* expo-secure-store). Owner unique, login fort (mdp + TOTP) côté backend.
|
||||||
|
*/
|
||||||
|
interface AuthState {
|
||||||
|
token: string | null;
|
||||||
|
ready: boolean;
|
||||||
|
login: (user: string, password: string, totp: string) => Promise<void>;
|
||||||
|
logout: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const KEY = "chlova.token";
|
||||||
|
const Ctx = createContext<AuthState | null>(null);
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [token, setToken] = useState<string | null>(null);
|
||||||
|
const [ready, setReady] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void SecureStore.getItemAsync(KEY).then((t) => {
|
||||||
|
setToken(t);
|
||||||
|
setReady(true);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const login = async (user: string, password: string, totp: string): Promise<void> => {
|
||||||
|
const { token: t } = await api.login(user, password, totp);
|
||||||
|
await SecureStore.setItemAsync(KEY, t);
|
||||||
|
setToken(t);
|
||||||
|
};
|
||||||
|
|
||||||
|
const logout = async (): Promise<void> => {
|
||||||
|
await SecureStore.deleteItemAsync(KEY);
|
||||||
|
setToken(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return <Ctx.Provider value={{ token, ready, login, logout }}>{children}</Ctx.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth(): AuthState {
|
||||||
|
const ctx = useContext(Ctx);
|
||||||
|
if (!ctx) throw new Error("useAuth hors AuthProvider");
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
// Tokens CHLOVA (dark HUD "Jarvis-red") — alignés sur docs/ui-design.md.
|
||||||
|
export const C = {
|
||||||
|
bg: "#020617",
|
||||||
|
surface: "#0f172a",
|
||||||
|
surface2: "#1e293b",
|
||||||
|
border: "#334155",
|
||||||
|
fg: "#f8fafc",
|
||||||
|
muted: "#94a3b8",
|
||||||
|
accent: "#ff3b3b",
|
||||||
|
danger: "#b91c1c",
|
||||||
|
success: "#22c55e",
|
||||||
|
warning: "#f59e0b",
|
||||||
|
};
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"extends": "expo/tsconfig.base",
|
||||||
|
"compilerOptions": {
|
||||||
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user