Updated some legal screen stuff

This commit is contained in:
Alois 2026-03-26 16:34:57 +00:00
commit ea72de0f08
6 changed files with 215 additions and 306 deletions

View file

@ -63,14 +63,6 @@ export default function Form() {
const { save } = useStorage();
const navigate = useNavigate();
/**
* Opens the hidden file input when the upload tile is clicked.
* @returns Void.
*/
const handleUploadTileClick = React.useCallback((): void => {
uploadRef.current?.click();
}, []);
/**
* Handles uploaded .tu files and stores resolved credentials.
* @param event Change event from the hidden file input.
@ -105,7 +97,7 @@ export default function Form() {
* @returns Promise that resolves after login processing.
*/
const handleCredentialsSubmit = React.useCallback(
async (event: React.FormEvent<HTMLFormElement>): Promise<void> => {
async (event: React.SubmitEvent<HTMLFormElement>): Promise<void> => {
event.preventDefault();
const formData = new FormData(event.currentTarget);
@ -135,7 +127,7 @@ export default function Form() {
await save("user_id", user.data.user_id);
await save("private_key", inputParse.data.private_key);
void navigate({ to: "/" });
navigate({ to: "/" });
} catch (error) {
log(0, "Login", "red", error);
toast("error", "Failed to fetch user data");
@ -145,47 +137,37 @@ export default function Form() {
);
return (
<div className="flex gap-5">
<Card className="w-75 h-80">
<CardHeader>
<CardTitle>Use .tu file</CardTitle>
</CardHeader>
<CardContent className="h-full flex items-center justify-center">
<div
onClick={handleUploadTileClick}
className="cursor-pointer w-60 aspect-square mb-17 bg-input/13 hover:bg-input/30 transition-all duration-300 ease-in-out border-dotted border-input/75 border-3 flex items-center justify-center rounded-lg"
>
<Upload className="text-input/75" size={34} />
</div>
<input
onChange={handleFileInputChange}
type="file"
ref={uploadRef}
className="hidden"
/>
</CardContent>
</Card>
<Card className="w-75 h-auto">
<CardHeader>
<CardTitle>Use credentials</CardTitle>
</CardHeader>
<CardContent>
<form
className="flex flex-col gap-5 h-full"
onSubmit={handleCredentialsSubmit}
>
<div className="flex flex-col gap-2">
<Label htmlFor="username">Username</Label>
<Input type="text" id="username" name="username" />
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="private_key">Private Key</Label>
<Input type="password" id="private_key" name="private_key" />
</div>
<Button type="submit">Login</Button>
</form>
</CardContent>
</Card>
<div className="flex gap-15">
<div
onClick={() => uploadRef.current?.click()}
className="flex flex-col gap-3 cursor-pointer w-55 aspect-square bg-input/13 hover:bg-input/30 transition-all duration-300 ease-in-out border-3 items-center justify-center rounded-lg"
>
<Upload className="text-foreground" size={27} />
<p className="text-md">Upload .tu file</p>
</div>
<input
accept=".tu"
onChange={handleFileInputChange}
type="file"
ref={uploadRef}
hidden
/>
<form
className="flex flex-col gap-5 aspect-square w-55"
onSubmit={handleCredentialsSubmit}
>
<div className="flex flex-col gap-2">
<Label htmlFor="username">Username</Label>
<Input required type="text" id="username" name="username" />
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="private_key">Private Key</Label>
<Input required type="password" id="private_key" name="private_key" />
</div>
<Button className="mt-auto" type="submit">
Login
</Button>
</form>
</div>
);
}

View file

@ -1,4 +1,4 @@
import * as React from "react";
import { useState, useCallback, useEffect } from "react";
import { useStorage } from "@tensamin/storage/context";
import { Button } from "@tensamin/ui/cmp/button";
import { Checkbox } from "@tensamin/ui/cmp/checkbox";
@ -11,112 +11,39 @@ import { log } from "@tensamin/shared/log";
import Link from "@tensamin/ui/link";
import { Label } from "@tensamin/ui/cmp/label";
type SaveFn = ReturnType<typeof useStorage>["save"];
/**
* Persists accepted legal documents and marks the first onboarding step complete.
* @param save Storage save function.
* @param currentDocs Current legal documents fetched from the server.
* @param setPPandToSDone State setter for legal acceptance completion.
* @returns Promise that resolves when persistence is complete.
*/
async function persistAcceptedDocs(
save: SaveFn,
currentDocs: z.infer<typeof legalDocsSchema>,
setPPandToSDone: React.Dispatch<React.SetStateAction<boolean>>,
): Promise<void> {
await save("accepted_privacy_policy", true);
await save("accepted_terms_of_service", true);
await save("ppandtos_done", true);
await save("legal_docs", currentDocs);
setPPandToSDone(true);
}
/**
* Persists analytics preference toggles and marks analytics onboarding complete.
* @param save Storage save function.
* @param crashReports Whether crash reports are enabled.
* @param usageData Whether usage data is enabled.
* @param setCrashReports State setter for crash reports.
* @param setUsageData State setter for usage data.
* @param setDoneWithAnalytics State setter for analytics completion.
* @returns Promise that resolves when persistence is complete.
*/
async function persistAnalyticsPreferences(
save: SaveFn,
crashReports: boolean,
usageData: boolean,
setCrashReports: React.Dispatch<React.SetStateAction<boolean>>,
setUsageData: React.Dispatch<React.SetStateAction<boolean>>,
setDoneWithAnalytics: React.Dispatch<React.SetStateAction<boolean>>,
): Promise<void> {
await save("analytics_crash_reports", crashReports);
setCrashReports(crashReports);
await save("analytics_usage_data", usageData);
setUsageData(usageData);
await save("analytics_done", true);
setDoneWithAnalytics(true);
}
/**
* Gates the application behind legal and analytics consent checks.
* @param props Component props containing children to render after consent.
* @returns Legal onboarding or wrapped children JSX.
*/
// Prevents the user from using Tensamin without accepting the privacy policy and terms of service.
export default function Screen(props: { children: React.ReactNode }) {
const { load, save } = useStorage();
const [error, setError] = React.useState("");
const [errorDescription, setErrorDescription] = React.useState("");
const [error, setError] = useState("");
const [errorDescription, setErrorDescription] = useState("");
const [remoteDocs, setRemoteDocs] = React.useState<
const [remoteDocs, setRemoteDocs] = useState<
z.infer<typeof legalDocsSchema> | undefined
>(undefined);
const [loading, setLoading] = React.useState(true);
const [loading, setLoading] = useState(true);
const [PPandToSDone, setPPandToSDone] = React.useState(false);
const [acceptedPP, acceptPP] = React.useState(false);
const [acceptedTOS, acceptTOS] = React.useState(false);
const [acceptedPP, acceptPP] = useState(false);
const [acceptedTOS, acceptTOS] = useState(false);
const [hasContinued, setHasContinued] = useState(false);
const [doneWithAnalytics, setDoneWithAnalytics] = React.useState(false);
const [crashReports, setCrashReports] = React.useState(false);
const [usageData, setUsageData] = React.useState(false);
const [userId, setUserId] = useState<number | undefined>(undefined);
/**
* Handles continue action for privacy policy and terms acceptance.
* @returns Void.
*/
const handleContinueLegal = React.useCallback((): void => {
// Saves
const handleContinueLegal = useCallback((): void => {
const currentDocs = remoteDocs;
if (!currentDocs) {
return;
}
void persistAcceptedDocs(save, currentDocs, setPPandToSDone);
save("accepted_privacy_policy", true);
save("accepted_terms_of_service", true);
save("legal_docs", currentDocs);
setHasContinued(true);
}, [remoteDocs, save]);
/**
* Handles continue action for analytics preferences.
* @returns Void.
*/
const handleContinueAnalytics = React.useCallback((): void => {
const currentCrashReports = crashReports;
const currentUsageData = usageData;
void persistAnalyticsPreferences(
save,
currentCrashReports,
currentUsageData,
setCrashReports,
setUsageData,
setDoneWithAnalytics,
);
}, [crashReports, save, usageData]);
React.useEffect(() => {
useEffect(() => {
let active = true;
void load("user_id").then(async (id) => {
@ -124,9 +51,9 @@ export default function Screen(props: { children: React.ReactNode }) {
return;
}
setUserId(id);
if (id === 0) {
setPPandToSDone(true);
setDoneWithAnalytics(true);
setLoading(false);
return;
}
@ -170,41 +97,24 @@ export default function Screen(props: { children: React.ReactNode }) {
const localDocs = await load("legal_docs");
const [
loadedPPAndTOS,
loadedAcceptedPP,
loadedAcceptedTOS,
loadedAnalyticsDone,
loadedCrashReports,
loadedUsageData,
] = await Promise.all([
load("ppandtos_done"),
const [loadedAcceptedPP, loadedAcceptedTOS] = await Promise.all([
load("accepted_privacy_policy"),
load("accepted_terms_of_service"),
load("analytics_done"),
load("analytics_crash_reports"),
load("analytics_usage_data"),
]);
if (!active) {
return;
}
setPPandToSDone(loadedPPAndTOS);
acceptPP(loadedAcceptedPP);
acceptTOS(loadedAcceptedTOS);
setDoneWithAnalytics(loadedAnalyticsDone);
setCrashReports(loadedCrashReports);
setUsageData(loadedUsageData);
if (localDocs.pp.hash !== safeCurrent.data.pp.hash) {
acceptPP(false);
setPPandToSDone(false);
}
if (localDocs.tos.hash !== safeCurrent.data.tos.hash) {
acceptTOS(false);
setPPandToSDone(false);
}
setLoading(false);
@ -219,91 +129,70 @@ export default function Screen(props: { children: React.ReactNode }) {
return <ErrorScreen error={error} description={errorDescription} />;
}
if (loading) {
if (loading || userId === undefined) {
return null;
}
if (PPandToSDone && doneWithAnalytics) {
if ((acceptedPP && acceptedTOS && hasContinued) || userId === 0) {
return <>{props.children}</>;
}
return (
<div className="w-full h-full flex items-center justify-center">
<div className="h-full flex flex-col gap-15 p-10 md:p-40 w-full lg:w-2/3">
{!PPandToSDone ? (
<>
<h1 className="text-3xl md:text-4xl font-bold">
Privacy Policy & ToS
<p className="text-muted-foreground text-[20px] font-normal pt-3">
{remoteDocs?.pp.version} / {remoteDocs?.tos.version}
</p>
</h1>
<div className="w-full h-full flex flex-col items-center justify-center gap-5">
<div className="justify-start items-start flex flex-col gap-2">
<BigCheckbox
checked={acceptedPP}
onChange={acceptPP}
label="I agree to the Privacy Policy"
/>
<BigCheckbox
checked={acceptedTOS}
onChange={acceptTOS}
label="I agree to the Terms of Service"
/>
<div className="w-full border-t-2" />
<Link
label="Privacy Policy"
link={`https://legal.tensamin.net/pp/${remoteDocs?.pp.version}`}
/>
<Link
label="Terms of Service"
link={`https://legal.tensamin.net/tos/${remoteDocs?.tos.version}`}
/>
</div>
</div>
<ContinueButton
disabled={!acceptedPP || !acceptedTOS}
onClick={handleContinueLegal}
<h1 className="text-3xl md:text-4xl font-bold">
Privacy Policy & ToS
<p className="text-muted-foreground text-[20px] font-normal pt-3">
{remoteDocs?.pp.version} / {remoteDocs?.tos.version}
</p>
</h1>
<div className="w-full h-full flex flex-col items-center justify-center gap-5">
<div className="justify-start items-start flex flex-col gap-2">
<BigCheckbox
checked={acceptedPP}
onChange={acceptPP}
label="I agree to the Privacy Policy"
/>
</>
) : (
<>
<h1 className="text-3xl md:text-4xl font-bold">Analytics</h1>
<div className="w-full h-full flex justify-center items-center">
<div className="justify-start flex flex-col gap-2">
<BigCheckbox
checked={crashReports}
onChange={setCrashReports}
label="Send anonymous crash reports"
/>
<BigCheckbox
checked={usageData}
onChange={setUsageData}
label="Send anonymous usage data"
/>
</div>
</div>
<ContinueButton onClick={handleContinueAnalytics} />
</>
)}
<BigCheckbox
checked={acceptedTOS}
onChange={acceptTOS}
label="I agree to the Terms of Service"
/>
<div className="w-full border-t-2" />
<Link
label="Privacy Policy"
link={`https://legal.tensamin.net/pp/${remoteDocs?.pp.version}`}
/>
<Link
label="Terms of Service"
link={`https://legal.tensamin.net/tos/${remoteDocs?.tos.version}`}
/>
</div>
</div>
<ContinueButton
disabled={!acceptedPP || !acceptedTOS}
onClick={handleContinueLegal}
/>
</div>
</div>
);
}
/**
* Renders a large continue button used by legal and analytics steps.
* @param props Button props with click callback and disabled state.
* @returns Continue button JSX.
*/
function ContinueButton(props: { onClick: () => void; disabled?: boolean }) {
// Components
function ContinueButton({
onClick,
disabled,
}: {
onClick: () => void;
disabled?: boolean;
}) {
return (
<div className="w-full flex justify-end">
<Button
size="lg"
className="text-lg w-full md:w-auto"
onClick={props.onClick}
disabled={props.disabled}
className="text-md w-full md:w-auto"
onClick={onClick}
disabled={disabled}
>
Continue
</Button>
@ -311,12 +200,11 @@ function ContinueButton(props: { onClick: () => void; disabled?: boolean }) {
);
}
/**
* Renders a larger checkbox row for onboarding preferences.
* @param props Checkbox label, current value, and change callback.
* @returns Checkbox row JSX.
*/
export function BigCheckbox(props: {
export function BigCheckbox({
label,
checked,
onChange,
}: {
label: string;
checked: boolean;
onChange: (checked: boolean) => void;
@ -324,11 +212,11 @@ export function BigCheckbox(props: {
return (
<div className="flex items-center space-x-2">
<Checkbox
checked={props.checked}
onCheckedChange={props.onChange}
checked={checked}
onCheckedChange={onChange}
className="size-5.5 rounded-md flex items-center justify-center"
/>
<Label className="text-lg">{props.label}</Label>
<Label className="text-lg">{label}</Label>
</div>
);
}

View file

@ -1,10 +1,12 @@
import Socket from "@tensamin/ttp/context";
import User from "@tensamin/user/context";
import type { ReactNode } from "react";
import { useEffect, useState, type ReactNode } from "react";
import { useNavigate, useLocation } from "@tanstack/react-router";
import Sidebar from "@/components/sidebar";
import Conversation from "@/features/conversation/context";
import Navbar from "@/components/navbar";
import { useStorage } from "@tensamin/storage/context";
/**
* Executes Layout.
@ -12,8 +14,30 @@ import Navbar from "@/components/navbar";
* @returns unknown.
*/
export default function Layout(props: { children: ReactNode }) {
const [loggedIn, setLoggedIn] = useState<boolean | null>(null);
const { load } = useStorage();
const location = useLocation();
const navigate = useNavigate();
useEffect(() => {
console.log(location);
load("user_id").then((userId) => {
if (userId !== 0) {
setLoggedIn(true);
} else {
navigate({
to: "/login",
});
setLoggedIn(false);
}
});
}, [location]);
return (
<Socket>
<Socket blockConnection={!loggedIn}>
<User>
<Conversation>
<div className="w-full h-full flex bg-sidebar">

View file

@ -1,6 +1,4 @@
import Form from "@/components/screens/login/form";
import { Button } from "@tensamin/ui/cmp/button";
import { Link } from "@tanstack/react-router";
/**
* Executes Page.
@ -12,9 +10,12 @@ export default function Page() {
<div className="w-full h-full flex flex-col gap-10 items-center justify-center">
<Form />
<div className="flex">
<Link to="/signup">
<Button variant="outline">Sign up</Button>
</Link>
<a target="_blank" href="https://docs.tensamin.net/installation/#iota">
<p className="text-sm underline text-foreground/75">
Don't have an account yet? Click here to get help setting up an
Iota.
</p>
</a>
</div>
</div>
);

View file

@ -1,4 +1,13 @@
import * as React from "react";
import {
useState,
createContext,
type ReactNode,
useRef,
useEffect,
useContext,
useCallback,
useMemo,
} from "react";
import { useCrypto } from "@tensamin/crypto/context";
import { log } from "@tensamin/shared/log";
import { useStorage } from "@tensamin/storage/context";
@ -129,34 +138,35 @@ type ContextType = {
identified: () => boolean;
};
const socketContext = React.createContext<ContextType | undefined>(undefined);
const socketContext = createContext<ContextType | undefined>(undefined);
/**
* Provides socket transport state and authenticated send operations to children.
* @param props Component props with children.
* @returns Loading, error, or provider-wrapped JSX.
*/
export default function Provider(props: { children: React.ReactNode }) {
export default function Provider(props: {
children: ReactNode;
blockConnection?: boolean;
}) {
const { load } = useStorage();
const { decrypt, getSharedSecret } = useCrypto();
const [readyState, setReadyState] = React.useState<number>(
READY_STATE.CLOSED,
);
const [connected, setConnected] = React.useState<boolean>(false);
const [identified, setIdentified] = React.useState<boolean>(false);
const [identifying, setIdentifying] = React.useState<boolean>(false);
const [readyState, setReadyState] = useState<number>(READY_STATE.CLOSED);
const [connected, setConnected] = useState<boolean>(false);
const [identified, setIdentified] = useState<boolean>(false);
const [identifying, setIdentifying] = useState<boolean>(false);
const [ownPing, setOwnPing] = React.useState<number>(0);
const [iotaPing, setIotaPing] = React.useState<number>(0);
const [ownPing, setOwnPing] = useState<number>(0);
const [iotaPing, setIotaPing] = useState<number>(0);
const [error, setError] = React.useState("");
const [errorDescription, setErrorDescription] = React.useState("");
const [error, setError] = useState("");
const [errorDescription, setErrorDescription] = useState("");
const clientRef = React.useRef<ReturnType<
const clientRef = useRef<ReturnType<
typeof createTransportClient<Schemas>
> | null>(null);
const identificationStartedRef = React.useRef(false);
const identificationStartedRef = useRef(false);
/**
* Sends typed protocol messages through the active transport client.
@ -165,7 +175,7 @@ export default function Provider(props: { children: React.ReactNode }) {
* @param options Optional request id and response mode.
* @returns A promise for either void (no response) or typed message payload.
*/
const send = React.useCallback<BoundSendFn<Schemas>>(
const send = useCallback<BoundSendFn<Schemas>>(
((
type: string,
data?: Record<string, unknown>,
@ -192,7 +202,7 @@ export default function Provider(props: { children: React.ReactNode }) {
[],
);
React.useEffect(() => {
useEffect(() => {
if (!connected || !identified) {
return;
}
@ -222,7 +232,7 @@ export default function Provider(props: { children: React.ReactNode }) {
};
}, [connected, identified, send]);
React.useEffect(() => {
useEffect(() => {
let attempts = 0;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let reconnectResetTimer: ReturnType<typeof setTimeout> | null = null;
@ -296,43 +306,45 @@ export default function Provider(props: { children: React.ReactNode }) {
}, RETRY_INTERVAL);
};
const transportClient = createTransportClient(schemas, {
url: TRANSPORT_URL,
onReadyStateChange: (state) => {
setReadyState(state);
const transportClient = !props.blockConnection
? createTransportClient(schemas, {
url: TRANSPORT_URL,
onReadyStateChange: (state) => {
setReadyState(state);
if (state === READY_STATE.OPEN) {
clearReconnectTimer();
scheduleReconnectReset();
identificationStartedRef.current = false;
setConnected(true);
setIdentified(false);
setError("");
setErrorDescription("");
return;
}
if (state === READY_STATE.OPEN) {
clearReconnectTimer();
scheduleReconnectReset();
identificationStartedRef.current = false;
setConnected(true);
setIdentified(false);
setError("");
setErrorDescription("");
return;
}
clearReconnectResetTimer();
identificationStartedRef.current = false;
setConnected(false);
setIdentified(false);
},
onClose: ({ error: closeError, intentional }) => {
clearReconnectResetTimer();
setConnected(false);
setIdentified(false);
setIdentifying(false);
clearReconnectResetTimer();
identificationStartedRef.current = false;
setConnected(false);
setIdentified(false);
},
onClose: ({ error: closeError, intentional }) => {
clearReconnectResetTimer();
setConnected(false);
setIdentified(false);
setIdentifying(false);
if (disposed || intentional) {
return;
}
if (disposed || intentional) {
return;
}
log(0, "Socket", "red", "Disconnected", closeError, {
stopSending: isStopSendingError(closeError),
});
scheduleReconnect(closeError);
},
});
log(0, "Socket", "red", "Disconnected", closeError, {
stopSending: isStopSendingError(closeError),
});
scheduleReconnect(closeError);
},
})
: null;
clientRef.current = transportClient;
@ -346,7 +358,7 @@ export default function Provider(props: { children: React.ReactNode }) {
}
try {
await transportClient.connect(TRANSPORT_URL);
await transportClient?.connect(TRANSPORT_URL);
} catch (connectError) {
if (disposed) {
return;
@ -368,7 +380,7 @@ export default function Provider(props: { children: React.ReactNode }) {
clientRef.current = null;
}
void transportClient.close("context-dispose");
void transportClient?.close("context-dispose");
setReadyState(READY_STATE.CLOSED);
setConnected(false);
setIdentified(false);
@ -377,7 +389,7 @@ export default function Provider(props: { children: React.ReactNode }) {
};
}, []);
React.useEffect(() => {
useEffect(() => {
if (!connected) {
setIdentifying(false);
setIdentified(false);
@ -489,7 +501,7 @@ export default function Provider(props: { children: React.ReactNode }) {
};
}, [connected, decrypt, getSharedSecret, load, send]);
const progress = React.useMemo(() => {
const progress = useMemo(() => {
if (readyState === READY_STATE.CONNECTING) return 30;
if (!connected) return 45;
if (identifying) return 75;
@ -497,7 +509,7 @@ export default function Provider(props: { children: React.ReactNode }) {
return 100;
}, [connected, identified, identifying, readyState]);
const loadingTitle = React.useMemo(() => {
const loadingTitle = useMemo(() => {
if (readyState === READY_STATE.CONNECTING || !connected) {
return "Connecting to Tensamin";
}
@ -509,7 +521,7 @@ export default function Provider(props: { children: React.ReactNode }) {
return "Loading";
}, [connected, identified, identifying, readyState]);
const loadingDescription = React.useMemo(() => {
const loadingDescription = useMemo(() => {
if (readyState === READY_STATE.CONNECTING || !connected) {
return "Establishing transport channel";
}
@ -521,7 +533,7 @@ export default function Provider(props: { children: React.ReactNode }) {
return undefined;
}, [connected, identified, identifying, readyState]);
const contextValue = React.useMemo<ContextType>(
const contextValue = useMemo<ContextType>(
() => ({
send,
readyState: () => readyState,
@ -559,7 +571,7 @@ export default function Provider(props: { children: React.ReactNode }) {
* @returns Socket context API for transport operations and connection state.
*/
export function useSocket(): ContextType {
const context = React.useContext(socketContext);
const context = useContext(socketContext);
if (!context) {
throw new Error("useSocket must be used within a SocketProvider");
}

View file

@ -1 +1,3 @@
- Calculate message height and pass to virtualizer (-> packages/chat/src/components/input.tsx)
- Review legal screen stuff
- Fix that location change doesn't trigger loggedIn so the user is stuck on a connecting page