client/apps/web/src/components/screens/login/form.tsx
Alois 3225b4e652
All checks were successful
/ deploy (push) Successful in 14m48s
(feat): add custom ttp urls
(qol): update todo
2026-05-11 14:36:12 +02:00

250 lines
7.7 KiB
TypeScript

import { Button } from "@tensamin/ui";
import { Input } from "@tensamin/ui";
import { Label } from "@tensamin/ui";
import { useStorage } from "@tensamin/storage/context";
import { log, toast } from "@tensamin/shared/log";
import { File } from "lucide-react";
import * as React from "react";
import { z } from "zod";
import { isTauri } from "@tauri-apps/api/core";
import QrCodeScanner from "@tensamin/tauri/qrCodeScanner";
const fetchedUser = z.object({
id: z.uuidv4(),
type: z.string(),
data: z.object({
iota_id: z.number(),
username: z.string(),
sub_level: z.number(),
public_key: z.base64(),
user_id: z.number(),
sub_end: z.number(),
}),
});
const formSchema = z.object({
username: z.string().min(1).max(255),
private_key: z.string().min(1).max(92),
});
/**
* Parses a .tu file payload into credentials.
* @param rawFileContent UTF-8 file content from an uploaded .tu file.
* @returns Parsed user id and private key credentials.
*/
function parseTuFileContent(rawFileContent: string): {
userId: number;
privateKey: string;
domain: string | null;
} {
if (rawFileContent.trim().length === 0) {
throw new Error("File is empty");
} else if (!rawFileContent.includes("::")) {
throw new Error("Invalid file");
} else if (rawFileContent.split("::").length !== 2) {
throw new Error("Invalid file");
}
const left = rawFileContent.split("::")[0];
const right = rawFileContent.split("::")[1];
if (left.length === 0) {
throw new Error("Invalid file");
} else if (right.length === 0) {
throw new Error("Invalid file");
} else if (isNaN(Number(left)) && !left.includes("@")) {
throw new Error("Invalid file");
}
const [userIdString, privateKey] = rawFileContent.split("::");
const userId = isNaN(Number(userIdString))
? Number(userIdString.split("@")[0])
: Number(userIdString);
const rawDomain = userIdString.includes("@")
? userIdString.split("@")[1]
: null;
const domain = rawDomain ? (rawDomain.includes(":") ? rawDomain : rawDomain + ":1984") : null;
if (!userId || !privateKey) {
throw new Error("Invalid file");
}
console.log({
domain,
rawDomain,
userId,
});
return { userId, privateKey, domain };
}
/**
* Renders the login form for file upload and manual credential login.
* @returns Login form JSX.
*/
export default function Form() {
const uploadRef = React.useRef<HTMLInputElement | null>(null);
const { save } = useStorage();
/**
* Handles uploaded .tu files and stores resolved credentials.
* @param event Change event from the hidden file input.
* @returns Promise that resolves when processing has finished.
*/
const handleFileInputChange = React.useCallback(
async (event: React.ChangeEvent<HTMLInputElement>): Promise<void> => {
try {
const file = event.currentTarget.files?.[0];
if (!file) {
throw new Error("No file selected");
}
const raw = await file.text();
const parsed = parseTuFileContent(raw);
await save("session_id", Date.now());
await save("user_id", parsed.userId);
await save("private_key", parsed.privateKey);
if (parsed.domain) {
await save("ttp_url", `https://${parsed.domain}/`);
}
location.href = "/";
} catch (error) {
log(0, "login", "red", error);
toast("error", "Failed to load file");
}
},
[save],
);
/**
* Handles username and private key login submission.
* @param event Form submit event.
* @returns Promise that resolves after login processing.
*/
const handleCredentialsSubmit = React.useCallback(
async (event: React.SubmitEvent<HTMLFormElement>): Promise<void> => {
event.preventDefault();
const formData = new FormData(event.currentTarget);
const rawData = Object.fromEntries(formData);
const inputParse = formSchema.safeParse(rawData);
if (!inputParse.success) {
toast("error", "Please enter valid data");
return;
}
const inputUsername = inputParse.data.username;
const actualUsername = inputUsername.includes("@") ? inputUsername.split("@")[0] : inputUsername;
const rawDomain = inputUsername.includes("@") ? inputUsername.split("@")[1] : null;
const domain = rawDomain ? (rawDomain.includes(":") ? rawDomain : rawDomain + ":1984") : null;
try {
const response = await fetch(
`https://omega.tensamin.net/api/get/id/${actualUsername}`,
);
const rawData = await response.arrayBuffer();
const bytes = new Uint8Array(rawData);
const jsonString = new TextDecoder().decode(bytes);
const data = JSON.parse(jsonString);
const parse = fetchedUser.shape.data.safeParse(data);
if (!parse.success) {
log(0, "login", "red", "Invalid response from server", parse.error);
toast("error", "Invalid response from server");
return;
}
const user = parse.data;
await save("session_id", Date.now());
await save("user_id", user.user_id);
await save("private_key", inputParse.data.private_key);
if (domain) {
await save("ttp_url", `https://${domain}/`);
}
location.href = "/";
} catch (error) {
log(0, "login", "red", error);
toast("error", "Failed to fetch user data");
}
},
[save],
);
const isTauriEnv = isTauri();
return (
<div className="flex md:flex-row flex-col gap-15">
{isTauriEnv ? (
<>
<QrCodeScanner
onData={async (data) => {
if (!data.startsWith("tensamin://tu::")) {
toast("error", "Invalid QR code");
return;
} else {
const decoded = data.replace("tensamin://tu::", "");
try {
const { userId, privateKey, domain } =
parseTuFileContent(decoded);
await save("session_id", Date.now());
await save("user_id", userId);
await save("private_key", privateKey);
if (domain) {
await save("ttp_url", `https://${domain}/`);
}
location.href = "/";
} catch (error) {
log(0, "login", "red", error);
toast("error", "Failed to parse QR code data");
}
}
}}
/>
<Button onClick={() => uploadRef.current?.click()}>
Select .tu file
</Button>
</>
) : (
<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"
>
<File className="text-foreground" size={27} />
<p className="text-md">Select .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>
);
}