250 lines
7.7 KiB
TypeScript
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>
|
|
);
|
|
}
|