feat(web): patient web app with ЕМИАС (demo) and anonymous sign-in
A second channel to the same dialog engine as the Telegram bot: consent, onboarding, scales and check-ins come from app/dialog; the bot is unchanged. - API: /auth/emias (doctor's one-time connect code via EmiasMock), /auth/emias/demo (synthetic personas), /auth/anonymous (random token, nothing stored in the identity DB), /crisis/resources from crisis.yaml - web/: React + TS + Vite, mobile-first, safe areas, hash routing, tokens and texts isolated for a Capacitor mobile build Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react";
|
||||
|
||||
import type { AuthResult } from "../api/types";
|
||||
import { sessionStore, type Session } from "./store";
|
||||
|
||||
interface SessionApi {
|
||||
session: Session | null;
|
||||
signIn: (auth: AuthResult) => void;
|
||||
signOut: () => void;
|
||||
}
|
||||
|
||||
const Ctx = createContext<SessionApi | null>(null);
|
||||
|
||||
export function SessionProvider({ children }: { children: ReactNode }) {
|
||||
const [session, setSession] = useState<Session | null>(() => sessionStore.load());
|
||||
|
||||
const signIn = useCallback((auth: AuthResult) => {
|
||||
const next: Session = { token: auth.token, mode: auth.mode, displayName: auth.display_name };
|
||||
sessionStore.save(next);
|
||||
setSession(next);
|
||||
}, []);
|
||||
|
||||
const signOut = useCallback(() => {
|
||||
setSession((current) => {
|
||||
sessionStore.clear(current);
|
||||
return null;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const value = useMemo(() => ({ session, signIn, signOut }), [session, signIn, signOut]);
|
||||
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
|
||||
}
|
||||
|
||||
export function useSession(): SessionApi {
|
||||
const value = useContext(Ctx);
|
||||
if (!value) throw new Error("useSession outside SessionProvider");
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { AuthMode, Reply } from "../api/types";
|
||||
|
||||
/*
|
||||
* Session persistence behind one small interface. Web uses localStorage; the mobile shell swaps the backend for
|
||||
* secure device storage (e.g. Capacitor Preferences / Keychain) without touching screens.
|
||||
*/
|
||||
|
||||
export interface Session {
|
||||
token: string;
|
||||
mode: AuthMode;
|
||||
displayName: string | null;
|
||||
}
|
||||
|
||||
export type ChatEntry =
|
||||
| { id: string; from: "assistant"; reply: Reply; answered: boolean }
|
||||
| { id: string; from: "patient"; text: string };
|
||||
|
||||
interface KeyValue {
|
||||
get(key: string): string | null;
|
||||
set(key: string, value: string): void;
|
||||
remove(key: string): void;
|
||||
}
|
||||
|
||||
const browserStorage: KeyValue = {
|
||||
get: (k) => {
|
||||
try {
|
||||
return window.localStorage.getItem(k);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
set: (k, v) => {
|
||||
try {
|
||||
window.localStorage.setItem(k, v);
|
||||
} catch {
|
||||
/* private mode / quota: the session simply is not remembered */
|
||||
}
|
||||
},
|
||||
remove: (k) => {
|
||||
try {
|
||||
window.localStorage.removeItem(k);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const SESSION_KEY = "ptsr.session.v1";
|
||||
const chatKey = (token: string) => `ptsr.chat.v1.${token}`;
|
||||
|
||||
function read<T>(storage: KeyValue, key: string): T | null {
|
||||
const raw = storage.get(key);
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return JSON.parse(raw) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function createSessionStore(storage: KeyValue = browserStorage) {
|
||||
return {
|
||||
load: () => read<Session>(storage, SESSION_KEY),
|
||||
save: (s: Session) => storage.set(SESSION_KEY, JSON.stringify(s)),
|
||||
clear: (s: Session | null) => {
|
||||
storage.remove(SESSION_KEY);
|
||||
if (s) storage.remove(chatKey(s.token));
|
||||
},
|
||||
loadChat: (token: string) => read<ChatEntry[]>(storage, chatKey(token)) ?? [],
|
||||
saveChat: (token: string, entries: ChatEntry[]) => storage.set(chatKey(token), JSON.stringify(entries)),
|
||||
};
|
||||
}
|
||||
|
||||
export const sessionStore = createSessionStore();
|
||||
Reference in New Issue
Block a user