feat: Added QR login

fix: Fixed webview for windows builds
This commit is contained in:
Simeon Radivoev 2026-03-03 15:51:47 +02:00
parent 01b91aa48c
commit 4739b89933
Signed by: simeonradivoev
GPG key ID: 7611A451D2A5D37A
26 changed files with 545 additions and 83 deletions

View file

@ -0,0 +1,2 @@
@import "tailwindcss";
@plugin "daisyui";

View file

@ -0,0 +1,249 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Quick Login</title>
<link rel="stylesheet" href="./index.css" />
</head>
<body
class="flex overflow-hidden relative items-center justify-center min-h-screen bg-base-300"
>
<div class="flex flex-col gap-4">
<div class="flex flex-col items-center justify-center gap-4">
<h1 class="text-4xl font-semibold">Quick Login</h1>
<div
id="arc"
class="radial-progress"
aria-valuenow="70"
role="progressbar"
>
70%
</div>
<div class="alert alert-dash alert-info" id="expiryNote">
Fetching session info…
</div>
</div>
<form
id="loginForm"
class="fieldset bg-base-200 border-base-300 rounded-box w-xs border p-4"
>
<label class="label" for="host">Host</label>
<input
type="text"
id="host"
name="host"
placeholder="my-server.local"
autocomplete="off"
class="input"
/>
<label class="label" for="username">Username</label>
<input
type="text"
id="username"
name="username"
placeholder="your-username"
autocomplete="username"
class="input"
/>
<label class="label" for="password">Password</label>
<input
type="password"
id="password"
name="password"
placeholder="••••••••"
autocomplete="current-password"
class="input join-item"
/>
<button class="btn btn-primary mt-4" id="loginBtn" type="submit">
Login →
</button>
<button
onclick="handleCancel()"
class="btn btn-primary mt-4"
id="loginBtn"
type="button"
>
Cancel
</button>
<div class="alert hidden" id="statusPill">
<div class="dot"></div>
<span id="statusText"></span>
</div>
</form>
</div>
<script>
const _base = window.location.href.split("?")[0].replace(/\/[^\/]*$/, "");
const ENDPOINT = _base + "/login";
const STATUS_EP = _base + "/status";
const DEFAULTS_EP = _base + "/defaults";
const CANCEL_EP = _base + "/cancel";
const arc = document.getElementById("arc");
const expireBar = document.getElementById("expireBar");
const expiryNote = document.getElementById("expiryNote");
const loginForm = document.getElementById("loginForm");
const pill = document.getElementById("statusPill");
const statusText = document.getElementById("statusText");
let expiresAt = null;
let totalSpan = null;
function handleCancel() {
fetch(CANCEL_EP, { method: "POST" });
}
async function fetchDefault() {
try {
const res = await fetch(DEFAULTS_EP, { method: "GET" });
const data = await res.json();
document.getElementById("host").defaultValue = data.host;
document.getElementById("username").defaultValue =
data.username ?? "";
} catch (e) {
showStatus("Could not fetch defaults", "err");
}
}
fetchDefault();
async function fetchExpiry() {
try {
const res = await fetch(STATUS_EP, { method: "GET" });
const data = await res.json();
if (data.expires_at) {
expiresAt = new Date(data.expires_at);
totalSpan = data.max_time;
renderExpiryNote();
} else {
expiryNote.textContent = "No expiry info from server";
}
} catch (e) {
expiryNote.textContent = "Could not reach server";
}
}
function renderExpiryNote() {
if (!expiresAt) return;
const fmt = expiresAt.toLocaleString(undefined, {
weekday: "short",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
expiryNote.textContent = `Active until ${fmt}`;
}
fetchExpiry();
// Countdown timer
function updateTimer() {
const loginBtn = document.getElementById("loginBtn");
if (!expiresAt) {
arc.style.setProperty("--value", 70);
arc.innerHTML = "<small>loading…</small>";
return;
}
const remainMs = expiresAt - Date.now();
if (remainMs <= 0) {
arc.style.setProperty("--value", 0);
arc.innerHTML = "<small>Expired</small>";
loginBtn.disabled = true;
return;
}
const pct = Math.min(remainMs / (totalSpan || remainMs), 1);
arc.style.setProperty("--value", pct * 100);
const totalSec = Math.ceil(remainMs / 1000);
const d = Math.floor(totalSec / 86400);
const h = Math.floor((totalSec % 86400) / 3600);
const m = Math.floor((totalSec % 3600) / 60);
const s = totalSec % 60;
let main, sub;
if (d > 0) {
main = `${d}d`;
sub = `${h}h left`;
} else if (h > 0) {
main = `${h}h`;
sub = `${m}m left`;
} else if (m > 0) {
main = `${m}:${String(s).padStart(2, "0")}`;
sub = "left";
} else {
main = `${s}s`;
sub = "left";
}
arc.innerHTML = `${main}<br><small>${sub}</small>`;
}
updateTimer();
setInterval(updateTimer, 1000);
function showStatus(msg, type) {
pill.className = "alert " + type;
statusText.textContent = msg;
if (type === "ok") setTimeout(() => pill.classList.add("hidden"), 4000);
}
// Login submit
loginForm.onsubmit = async (e) => {
e.preventDefault();
if (!e.target.host.value) {
showStatus("Host is required", "alert-error");
return;
}
if (!e.target.username.value) {
showStatus("Username is required", "alert-error");
return;
}
if (!e.target.password.value) {
showStatus("Password is required", "alert-error");
return;
}
const btn = document.getElementById("loginBtn");
btn.textContent = "Logging in…";
btn.style.opacity = "0.75";
btn.disabled = true;
try {
const res = await fetch(ENDPOINT, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
host: e.target.host.value,
username: e.target.username.value,
password: e.target.password.value,
}),
});
if (res.ok) {
showStatus("Logged in!", "alert-success");
setTimeout(() => window.close(), 1000);
} else {
showStatus(`Error ${res.status}: ${res.statusText}`, "alert-error");
}
} catch (e) {
showStatus("Could not reach server", "alert-error");
} finally {
btn.textContent = "Login →";
btn.style.opacity = "1";
btn.disabled = false;
}
};
</script>
</body>
</html>

View file

@ -71,7 +71,7 @@ export function AnimatedBackground (data: {
backgroundSize: '100%',
backgroundPositionY: 'bottom',
backgroundPositionX: 'center',
backgroundBlendMode: 'soft-light',
backgroundBlendMode: blur ? 'normal' : 'soft-light',
backgroundColor: "var(--color-base-100)",
} : {}}
>

View file

@ -115,7 +115,7 @@ export function ContextDialog (data: {
<ContextDialogContext value={{ id: data.id, close: data.close }} >
<div
className={twMerge(
"bg-base-100/80 delay-200 rounded-4xl sm:p-4 md:p-6 sm:min-w-[80vw] md:min-w-[30vw] cursor-auto",
"bg-base-100/80 delay-200 rounded-4xl sm:p-4 md:p-6 sm:min-w-[80vw] md:min-w-[20vw] cursor-auto",
data.open ? "animate-scale-delayed" : "opacity-0",
data.className)
}

View file

@ -38,7 +38,7 @@ export function Button (data: {
classNames({
"btn-accent": focused,
}, data.className))}
type={data.type}
type={data.type ?? 'button'}
>
{data.children}
</button>;

View file

@ -87,7 +87,7 @@ export function OptionSpace (data: {
data.label
)}
</div>
<div className="flex">
<div className="flex grow justify-end-safe">
{data.children}
</div>
</li>

View file

@ -19,7 +19,7 @@ export const { useAppForm: useSettingsForm, useTypedAppFormContext: useSettingsF
function FormOption (data: { type: HTMLInputTypeAttribute, icon?: JSX.Element; label?: string | JSX.Element; placeholder?: string; })
{
const field = useFieldContext<string>();
return <OptionSpace label={<div className="flex gap-2">
return <OptionSpace label={<div className="flex flex-1 gap-2">
{data.label}
{field.getMeta().errors.length > 0 && <div className="badge badge-error">
{field.state.meta.errors.map(e => e.message).join(',')}
@ -32,7 +32,7 @@ function FormOption (data: { type: HTMLInputTypeAttribute, icon?: JSX.Element; l
type={data.type}
onChange={e => field.handleChange(e.target.value)}
placeholder={data.placeholder}
className={classNames({ "ring-4 ring-accent": field.getMeta().isDirty })}
className={classNames({ " flex-3 ring-4 ring-accent": field.getMeta().isDirty })}
/>
</OptionSpace>;;
}

View file

@ -1,12 +1,13 @@
import
{
FocusContext,
setFocus,
useFocusable,
} from "@noriginmedia/norigin-spatial-navigation";
import { useIsMutating, useMutation, useQuery } from "@tanstack/react-query";
import { createFileRoute } from "@tanstack/react-router";
import classNames from "classnames";
import { Key, Link, Lock, Save, Trash, User, X } from "lucide-react";
import { Key, Link, Lock, Save, ScanQrCode, Trash, User, X } from "lucide-react";
import
{
useEffect,
@ -23,11 +24,22 @@ import { OptionSpace } from "../../components/options/OptionSpace";
import { useSettingsForm, useSettingsFormContext } from "../../components/options/SettingsAppForm";
import { rommApi, settingsApi } from "../../scripts/clientApi";
import { Button } from "../../components/options/Button";
import { ContextDialog } from "@/mainview/components/ContextDialog";
import QRCode from "react-qr-code";
import { useAsyncGenerator } from "@/mainview/scripts/utils";
export const Route = createFileRoute("/settings/accounts")({
component: RouteComponent,
});
function LoginQR (data: { id: string, isOpen: boolean, cancel: () => void, url: string; endsAt: Date; })
{
return <ContextDialog id={data.id} open={data.isOpen} close={() => data.cancel()} className="flex flex-col justify-center items-center gap-2">
<QRCode value={data.url} />
<Button id="qr-login-cancel" focusClassName="btn-warning" type="button" onAction={() => data.cancel()}><X /> Cancel</Button>
</ContextDialog>;
}
function LoginControls (data: { hasPassword: boolean; })
{
const user = useQuery({
@ -36,8 +48,27 @@ function LoginControls (data: { hasPassword: boolean; })
refetchOnWindowFocus: false,
retry: 0
});
const { data: qrLoginStatusGen, refetch } = useQuery({
queryKey: ['login', 'qr'], queryFn: async () =>
{
const { data, error } = await rommApi.api.romm.login.remote.status.get();
if (error) throw error;
return data;
}
});
const statusValue = useAsyncGenerator(qrLoginStatusGen, [qrLoginStatusGen]);
const cancelQrMutation = useMutation({
mutationKey: ['login', 'qr', 'cancel'],
mutationFn: () => rommApi.api.romm.login.remote.cancel.post(),
onSuccess: () => refetch()
});
const requestQrLoginMutation = useMutation({
mutationKey: ['login', 'qr'],
mutationFn: () => rommApi.api.romm.login.remote.start.post(),
onSuccess: () => refetch()
});
const context = useSettingsFormContext({});
context.state.canSubmit;
const isMutatingRomm = useIsMutating({ mutationKey: ["romm", "auth"] }) > 0;
const logoutMutation = useMutation({
mutationKey: ["romm", "auth", "logout"], mutationFn: () => rommApi.api.romm.logout.post(),
@ -52,6 +83,7 @@ function LoginControls (data: { hasPassword: boolean; })
{user.isSuccess && <>
<div className="badge badge-success badge-lg rounded-full gap-2"> <p className="sm:hidden md:inline">Logged In As:</p> <img className="size-6 rounded-full" src={`${RPC_URL(__HOST__)}/api/romm/assets/romm/assets/${user.data?.avatar_path}`} /><b>{user.data?.username}</b></div>
</>}
<Button id="qr-login" type="button" onAction={() => requestQrLoginMutation.mutate()}><ScanQrCode /> </Button>
<Button id="can-submit" disabled={!context.state.canSubmit || !context.state.isDirty} type="submit" onAction={() => context.handleSubmit()} >
<Save /> Save
</Button>
@ -67,6 +99,11 @@ function LoginControls (data: { hasPassword: boolean; })
<Button id="cancel" disabled={context.state.isDefaultValue} type="reset" onAction={() => context.reset()}>
<X /> Cancel
</Button>
{statusValue?.data?.endsAt && <LoginQR id="qr-login-context" endsAt={statusValue.data.endsAt} isOpen={true} cancel={() =>
{
setFocus(`qr-login`);
cancelQrMutation.mutate();
}} url={statusValue?.data?.url ?? ''} />}
</div>;
}
@ -119,9 +156,10 @@ function RouteComponent ()
const loginMutation = useMutation({
mutationKey: ["romm", "login"],
mutationFn: (data: z.infer<typeof dataSchema>) =>
mutationFn: async (data: z.infer<typeof dataSchema>) =>
{
return rommApi.api.romm.login.post({ username: data.username, password: data.password, host: data.hostname });
const { error } = await rommApi.api.romm.login.post({ username: data.username, password: data.password, host: data.hostname });
if (error) throw error;
},
onSuccess: (d, v, r, c) =>
{

View file

@ -1,5 +1,5 @@
import { doesFocusableExist, getCurrentFocusKey } from "@noriginmedia/norigin-spatial-navigation";
import { RefObject, useEffect } from "react";
import { RefObject, useEffect, useState } from "react";
export function selfFocusSmart (shouldFocus: boolean, focusSelf: () => void)
{
@ -62,4 +62,41 @@ export function mobileCheck ()
let check = false;
(function (a) { if (/(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino/i.test(a) || /1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i.test(a.substr(0, 4))) check = true; })(navigator.userAgent || navigator.vendor || window.opera);
return check;
};
};
export function useAsyncGenerator<T> (
generator: AsyncGenerator<T> | undefined,
deps: any[]
)
{
const [value, setValue] = useState<T | null>(null);
useEffect(() =>
{
if (!generator)
{
setValue(null);
return;
}
let cancelled = false;
const run = async () =>
{
for await (const v of generator)
{
if (cancelled) break;
setValue(v);
}
};
run();
return () =>
{
cancelled = true;
};
}, deps);
return value;
}