feat: Added QR login
fix: Fixed webview for windows builds
This commit is contained in:
parent
01b91aa48c
commit
4739b89933
26 changed files with 545 additions and 83 deletions
2
src/mainview/auth/qr/index.css
Normal file
2
src/mainview/auth/qr/index.css
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
@import "tailwindcss";
|
||||
@plugin "daisyui";
|
||||
249
src/mainview/auth/qr/index.html
Normal file
249
src/mainview/auth/qr/index.html
Normal 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>
|
||||
|
|
@ -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)",
|
||||
} : {}}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ export function Button (data: {
|
|||
classNames({
|
||||
"btn-accent": focused,
|
||||
}, data.className))}
|
||||
type={data.type}
|
||||
type={data.type ?? 'button'}
|
||||
>
|
||||
{data.children}
|
||||
</button>;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>;;
|
||||
}
|
||||
|
|
@ -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) =>
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue