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>
);