feat: update LoginWithQR (#17)

* feat: update LoginWithQR

* Apperantly Catbox.moe doesn't like me

---------

Co-authored-by: thororen <78185467+thororen1234@users.noreply.github.com>
This commit is contained in:
nexpid 2024-07-24 07:59:50 +02:00 committed by GitHub
parent e3e255f5e7
commit 7c8e14f3ec
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 356 additions and 105 deletions

View file

@ -0,0 +1,41 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
export const images = {
cross: "https://i.imgur.com/XxRnu3b.png",
deviceImage: {
success:
"https://github.com/nexpid/Themelings/raw/data/icons/images/native/img_remote_auth_succeeded.png",
notFound:
"https://github.com/nexpid/Themelings/raw/data/icons/images/native/img_remote_auth_not_found.png",
loading:
"https://github.com/nexpid/Themelings/raw/data/icons/images/native/img_remote_auth_loaded.png",
},
} as const;
export let unload: () => void;
export function preload() {
const elements = new Array<HTMLElement>();
// Normally, we'd use link:preload (or link:prefetch), but
// Discord blocks third party prefetch domains and link:preload
// throws a warning, so we'll just put the images in the head
const browse = (dir: Record<string, any>) => {
for (const entry of Object.values(dir)) {
if (typeof entry === "string") {
const img = new Image();
img.setAttribute("data-purpose", "prefetch");
img.setAttribute("data-added-by", "LoginWithQR");
img.src = entry;
document.head.appendChild(img);
elements.push(img);
} else if (typeof entry === "object") browse(entry);
}
};
browse(images);
unload = () => elements.forEach(element => element.remove());
}

View file

@ -5,19 +5,22 @@
*/ */
import { definePluginSettings } from "@api/Settings"; import { definePluginSettings } from "@api/Settings";
import { EquicordDevs } from "@utils/constants";
import definePlugin, { OptionType } from "@utils/types"; import definePlugin, { OptionType } from "@utils/types";
import { findByProps } from "@webpack"; import { findByProps } from "@webpack";
import { Button, Forms, i18n, Menu, TabBar } from "@webpack/common"; import { Button, Forms, i18n, Menu, TabBar } from "@webpack/common";
import { ReactElement } from "react"; import { ReactElement } from "react";
import { preload, unload } from "./images";
import { cl, QrCodeCameraIcon } from "./ui"; import { cl, QrCodeCameraIcon } from "./ui";
import openQrModal from "./ui/modals/QrModal"; import openQrModal from "./ui/modals/QrModal";
import { EquicordDevs } from "@utils/constants";
export default definePlugin({ export default definePlugin({
name: "LoginWithQR", name: "LoginWithQR",
description: "Allows you to login to another device by scanning a login QR code, just like on mobile!", description:
"Allows you to login to another device by scanning a login QR code, just like on mobile!",
authors: [EquicordDevs.nexpid], authors: [EquicordDevs.nexpid],
settings: definePluginSettings({ settings: definePluginSettings({
scanQr: { scanQr: {
type: OptionType.COMPONENT, type: OptionType.COMPONENT,
@ -26,7 +29,7 @@ export default definePlugin({
if (!Vencord.Plugins.plugins.LoginWithQR.started) if (!Vencord.Plugins.plugins.LoginWithQR.started)
return ( return (
<Forms.FormText> <Forms.FormText>
Scan a login QR code Enable the plugin and restart your client to scan a login QR code
</Forms.FormText> </Forms.FormText>
); );
@ -38,6 +41,7 @@ export default definePlugin({
}, },
}, },
}), }),
patches: [ patches: [
// Prevent paste event from firing when the QRModal is open // Prevent paste event from firing when the QRModal is open
{ {
@ -80,7 +84,9 @@ export default definePlugin({
}, },
}, },
], ],
qrModalOpen: false, qrModalOpen: false,
insertScanQrButton: (button: ReactElement) => ( insertScanQrButton: (button: ReactElement) => (
<div className={cl("settings-btns")}> <div className={cl("settings-btns")}>
<Button size={Button.Sizes.SMALL} onClick={openQrModal}> <Button size={Button.Sizes.SMALL} onClick={openQrModal}>
@ -89,8 +95,11 @@ export default definePlugin({
{button} {button}
</div> </div>
), ),
get ScanQrMenuItem() { get ScanQrMenuItem() {
const { menuItemFocused, subMenuIcon } = findByProps("menuItemFocused") as Record<string, string>; const { menuItemFocused, subMenuIcon } = findByProps(
"menuItemFocused"
) as Record<string, string>;
return ( return (
<Menu.MenuGroup> <Menu.MenuGroup>
@ -106,9 +115,19 @@ export default definePlugin({
</Menu.MenuGroup> </Menu.MenuGroup>
); );
}, },
ScanQrTabBarComponent: () => ( ScanQrTabBarComponent: () => (
<TabBar.Item id="Scan QR Code" onClick={openQrModal}> <TabBar.Item id="Scan QR Code" onClick={openQrModal}>
{i18n.Messages.USER_SETTINGS_SCAN_QR_CODE} {i18n.Messages.USER_SETTINGS_SCAN_QR_CODE}
</TabBar.Item> </TabBar.Item>
), ),
start() {
// Preload images
preload();
},
stop() {
unload?.();
},
}); });

View file

@ -0,0 +1,12 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
declare module "jsqr" {
import jsQR, { QRCode } from "jsqr/dist";
export default jsQR;
export { QRCode };
}

View file

@ -4,7 +4,7 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
*/ */
import "./style.css"; import "./styles.css";
import { classNameFactory } from "@api/Styles"; import { classNameFactory } from "@api/Styles";
import { proxyLazy } from "@utils/lazy"; import { proxyLazy } from "@utils/lazy";

View file

@ -22,8 +22,10 @@ import {
useRef, useRef,
useState, useState,
} from "@webpack/common"; } from "@webpack/common";
import jsQR from "jsqr"; import jsQR, { QRCode } from "jsqr";
import { MutableRefObject, ReactElement } from "react";
import { images } from "../../images";
import { cl, Spinner, SpinnerTypes } from ".."; import { cl, Spinner, SpinnerTypes } from "..";
import openVerifyModal from "./VerifyModal"; import openVerifyModal from "./VerifyModal";
@ -33,14 +35,41 @@ enum LoginStateType {
Camera, Camera,
} }
interface Preview {
source: ReactElement;
size: {
width: number;
height: number;
};
crosses?: { x: number; y: number; rot: number; size: number; }[];
}
interface QrModalProps {
exit: (err: string | null) => void;
setPreview: (
media: HTMLImageElement | HTMLVideoElement | null,
location?: QRCode["location"]
) => Promise<void>;
closeMain: () => void;
}
type QrModalPropsRef = MutableRefObject<QrModalProps>;
const limitSize = (width: number, height: number) => {
if (width > height) {
const w = Math.min(width, 1280);
return { w, h: (height / width) * w };
} else {
const h = Math.min(height, 1280);
return { h, w: (width / height) * h };
}
};
const { getVideoDeviceId } = findByPropsLazy("getVideoDeviceId"); const { getVideoDeviceId } = findByPropsLazy("getVideoDeviceId");
const tokenRegex = /^https:\/\/discord\.com\/ra\/(.+)$/; const tokenRegex = /^https:\/\/discord\.com\/ra\/([\w-]+)$/;
const verifyUrl = async ( const verifyUrl = async (
token: string, token: string,
exit: (err: string | null) => void, { current: modalProps }: QrModalPropsRef
closeMain: () => void
) => { ) => {
// yay // yay
let handshake: string | null = null; let handshake: string | null = null;
@ -52,36 +81,41 @@ const verifyUrl = async (
if (res.ok && res.status === 200) handshake = res.body?.handshake_token; if (res.ok && res.status === 200) handshake = res.body?.handshake_token;
} catch { } } catch { }
modalProps.setPreview(null);
openVerifyModal( openVerifyModal(
handshake, handshake,
() => { () => {
exit(null); modalProps.exit(null);
RestAPI.post({ RestAPI.post({
url: "/users/@me/remote-auth/cancel", url: "/users/@me/remote-auth/cancel",
body: { handshake_token: handshake }, body: { handshake_token: handshake },
}); });
}, },
closeMain modalProps.closeMain
); );
}; };
const handleProcessImage = ( const handleProcessImage = (file: File, modalPropsRef: QrModalPropsRef) => {
file: File, const { current: modalProps } = modalPropsRef;
exit: (err: string | null) => void,
closeMain: () => void
) => {
const reader = new FileReader(); const reader = new FileReader();
reader.addEventListener("load", () => { reader.addEventListener("load", () => {
if (!reader.result) return; if (!reader.result) return;
const img = new Image(); const img = new Image();
img.addEventListener("load", () => { img.addEventListener("load", () => {
modalProps.setPreview(img);
const { w, h } = limitSize(img.width, img.height);
img.width = w;
img.height = h;
const canvas = document.createElement("canvas"); const canvas = document.createElement("canvas");
canvas.width = img.width; canvas.width = img.width;
canvas.height = img.height; canvas.height = img.height;
const ctx = canvas.getContext("2d")!; const ctx = canvas.getContext("2d")!;
ctx.drawImage(img, 0, 0); ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
const { data, width, height } = ctx.getImageData( const { data, width, height } = ctx.getImageData(
0, 0,
0, 0,
@ -92,10 +126,15 @@ const handleProcessImage = (
const token = code?.data.match(tokenRegex)?.[1]; const token = code?.data.match(tokenRegex)?.[1];
if (token) if (token)
verifyUrl(token, exit, closeMain).catch(e => modalProps
exit(e?.message) .setPreview(img, code.location)
); .then(() =>
else exit(null); verifyUrl(token, modalPropsRef).catch(e =>
modalProps.exit(e?.message)
)
);
else modalProps.exit(null);
canvas.remove(); canvas.remove();
}); });
img.src = reader.result as string; img.src = reader.result as string;
@ -105,9 +144,103 @@ const handleProcessImage = (
function QrModal(props: ModalProps) { function QrModal(props: ModalProps) {
const [state, setState] = useState(LoginStateType.Idle); const [state, setState] = useState(LoginStateType.Idle);
const inputRef = useRef<HTMLInputElement>(null); const [preview, setPreview] = useState<Preview | null>(null);
const error = useRef<string | null>(null); const error = useRef<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const modalProps = useRef<QrModalProps>({
exit: err => {
error.current = err;
setState(LoginStateType.Idle);
setPreview(null);
},
setPreview: (media, location) =>
new Promise(res => {
if (!media) return res(setPreview(null));
const size = {} as Preview["size"];
if (media.width > media.height) {
size.width = 34;
size.height = (media.height / media.width) * 34;
} else {
size.height = 34;
size.width = (media.width / media.height) * 34;
}
let source: ReactElement;
if (media instanceof HTMLImageElement)
source = (
<img src={media.src} style={{ width: "100%", height: "100%" }} />
);
else
source = (
<video
controls={false}
style={{ width: "100%", height: "100%" }}
ref={e =>
e &&
((e.srcObject = media.srcObject), !media.paused && e.play())
}
/>
);
if (!location) return res(setPreview({ source, size }));
else {
const combinations = [
[location.topLeftCorner, location.bottomRightCorner],
[location.topRightCorner, location.bottomLeftCorner],
];
const crossSize =
(Math.sqrt(
Math.abs(location.topLeftCorner.x - location.topRightCorner.x) **
2 +
Math.abs(
location.topLeftCorner.y - location.topRightCorner.y
) **
2
) +
Math.sqrt(
Math.abs(
location.topRightCorner.x - location.bottomRightCorner.x
) **
2 +
Math.abs(
location.topRightCorner.y - location.bottomRightCorner.y
) **
2
)) /
3 /
media.height;
const crosses = [] as NonNullable<Preview["crosses"]>;
for (const combination of combinations) {
for (let i = 0; i < 2; i++) {
const current = combination[i];
const opposite = combination[1 - i];
const rot =
(Math.atan2(opposite.y - current.y, opposite.x - current.x) -
Math.PI / 4) *
(180 / Math.PI);
crosses.push({
x: (current.x / media.width) * 100,
y: (current.y / media.height) * 100,
rot,
size: Math.min(crossSize * size.height, 7),
});
}
}
setPreview({ source, size, crosses });
setTimeout(res, 500 + 300 + 300);
}
}),
closeMain: props.onClose,
});
useEffect(() => { useEffect(() => {
const plugin = Vencord.Plugins.plugins.LoginWithQR as any; const plugin = Vencord.Plugins.plugins.LoginWithQR as any;
@ -123,13 +256,19 @@ function QrModal(props: ModalProps) {
for (const item of e.clipboardData.items) { for (const item of e.clipboardData.items) {
if (item.kind === "file" && item.type.startsWith("image/")) { if (item.kind === "file" && item.type.startsWith("image/")) {
setState(LoginStateType.Loading); setState(LoginStateType.Loading);
handleProcessImage( handleProcessImage(item.getAsFile()!, modalProps);
item.getAsFile()!, break;
err => ( } else if (item.kind === "string" && item.type === "text/plain") {
(error.current = err), setState(LoginStateType.Idle) item.getAsString(text => {
), setState(LoginStateType.Loading);
props.onClose
); const token = text.match(tokenRegex)?.[1];
if (token)
verifyUrl(token, modalProps).catch(e =>
modalProps.current.exit(e?.message)
);
else modalProps.current.exit(null);
});
break; break;
} }
} }
@ -143,15 +282,13 @@ function QrModal(props: ModalProps) {
useEffect(() => { useEffect(() => {
if (state !== LoginStateType.Camera) return; if (state !== LoginStateType.Camera) return;
const exit = (err: string | null) => (
(error.current = err), setState(LoginStateType.Idle)
);
const video = document.createElement("video"); const video = document.createElement("video");
video.width = 1280;
video.height = 720;
const canvas = document.createElement("canvas"); const canvas = document.createElement("canvas");
canvas.width = 1280; canvas.width = video.width;
canvas.height = 720; canvas.height = video.height;
const ctx = canvas.getContext("2d")!; const ctx = canvas.getContext("2d", { willReadFrequently: true })!;
let stream: MediaStream; let stream: MediaStream;
let snapshotTimeout: number; let snapshotTimeout: number;
@ -159,8 +296,9 @@ function QrModal(props: ModalProps) {
const stop = (stream: MediaStream) => ( const stop = (stream: MediaStream) => (
stream.getTracks().forEach(track => track.stop()), stream.getTracks().forEach(track => track.stop()),
setState(LoginStateType.Idle) modalProps.current.exit(null)
); );
const snapshot = () => { const snapshot = () => {
ctx.drawImage(video, 0, 0, canvas.width, canvas.height); ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
const { data, width, height } = ctx.getImageData( const { data, width, height } = ctx.getImageData(
@ -173,11 +311,21 @@ function QrModal(props: ModalProps) {
const token = code?.data.match(tokenRegex)?.[1]; const token = code?.data.match(tokenRegex)?.[1];
if (token) { if (token) {
setState(LoginStateType.Loading); const img = new Image();
verifyUrl(token, exit, props.onClose).catch(e => img.addEventListener("load", () =>
exit(e?.message) modalProps.current
.setPreview(img, code.location)
.then(
() => (
img.remove(),
verifyUrl(token, modalProps).catch(e =>
modalProps.current.exit(e?.message)
)
)
)
); );
} else snapshotTimeout = setTimeout(snapshot, 1000) as any; canvas.toBlob(blob => blob && (img.src = URL.createObjectURL(blob)));
} else snapshotTimeout = setTimeout(snapshot, 1e3) as any;
}; };
navigator.mediaDevices navigator.mediaDevices
@ -187,7 +335,7 @@ function QrModal(props: ModalProps) {
deviceId: getVideoDeviceId(), deviceId: getVideoDeviceId(),
width: canvas.width, width: canvas.width,
height: canvas.height, height: canvas.height,
frameRate: 5 frameRate: 30,
}, },
}) })
.then(str => { .then(str => {
@ -197,6 +345,7 @@ function QrModal(props: ModalProps) {
video.srcObject = str; video.srcObject = str;
video.addEventListener("loadedmetadata", () => { video.addEventListener("loadedmetadata", () => {
video.play(); video.play();
modalProps.current.setPreview(video);
snapshot(); snapshot();
}); });
}) })
@ -229,73 +378,78 @@ function QrModal(props: ModalProps) {
className={cl( className={cl(
"modal-filepaste", "modal-filepaste",
state === LoginStateType.Camera && state === LoginStateType.Camera &&
"modal-filepaste-disabled" !preview?.source &&
"modal-filepaste-disabled",
preview?.source && "modal-filepaste-preview"
)} )}
onClick={() => onClick={() =>
state === LoginStateType.Idle && state === LoginStateType.Idle && inputRef.current?.click()
inputRef.current?.click()
} }
onDragEnter={e => onDragEnter={e =>
e.currentTarget.classList.add( e.currentTarget.classList.add(cl("modal-filepaste-drop"))
cl("modal-filepaste-drop")
)
} }
onDragLeave={e => onDragLeave={e =>
e.currentTarget.classList.remove( e.currentTarget.classList.remove(cl("modal-filepaste-drop"))
cl("modal-filepaste-drop")
)
} }
onDrop={e => { onDrop={e => {
e.preventDefault(); e.preventDefault();
e.currentTarget.classList.remove( e.currentTarget.classList.remove(cl("modal-filepaste-drop"));
cl("modal-filepaste-drop")
);
if (state !== LoginStateType.Idle) return; if (state !== LoginStateType.Idle) return;
for (const item of e.dataTransfer.files) { for (const item of e.dataTransfer.files) {
if (item.type.startsWith("image/")) { if (item.type.startsWith("image/")) {
setState(LoginStateType.Loading); setState(LoginStateType.Loading);
handleProcessImage( handleProcessImage(item, modalProps);
item,
err => (
(error.current = err),
setState(LoginStateType.Idle)
),
props.onClose
);
break; break;
} }
} }
}} }}
role="button" role="button"
style={
preview?.size
? {
width: `${preview.size.width}rem`,
height: `${preview.size.height}rem`,
}
: {}
}
> >
{state === LoginStateType.Loading ? ( {preview?.source ? (
<div
style={{ width: "100%", height: "100%", position: "relative" }}
>
{preview.source}
{preview.crosses?.map(({ x, y, rot, size }, i) => (
<span
className={cl("preview-cross")}
style={{
left: `${x}%`,
top: `${y}%`,
["--size" as any]: `${size}rem`,
["--rot" as any]: `${rot}deg`,
}}
>
<img src={images.cross} draggable={false} />
</span>
))}
</div>
) : state === LoginStateType.Loading ? (
<Spinner type={SpinnerTypes.WANDERING_CUBES} /> <Spinner type={SpinnerTypes.WANDERING_CUBES} />
) : error.current ? ( ) : error.current ? (
<Text color="text-danger" variant="heading-md/semibold"> <Text color="text-danger" variant="heading-md/semibold">
{error.current} {error.current}
</Text> </Text>
) : state === LoginStateType.Camera ? ( ) : state === LoginStateType.Camera ? (
<Text <Text color="header-primary" variant="heading-md/semibold">
color="header-primary"
variant="heading-md/semibold"
>
Scanning... Scanning...
</Text> </Text>
) : ( ) : (
<> <>
<Text <Text color="header-primary" variant="heading-md/semibold">
color="header-primary" Drag and drop an image here, or click to select an image
variant="heading-md/semibold"
>
Drag and drop an image here, or click to select
an image
</Text> </Text>
<Text <Text color="text-muted" variant="heading-sm/medium">
color="text-muted"
variant="heading-sm/medium"
>
Or paste an image from your clipboard! Or paste an image from your clipboard!
</Text> </Text>
</> </>
@ -305,20 +459,12 @@ function QrModal(props: ModalProps) {
type="file" type="file"
accept="image/*" accept="image/*"
onChange={e => { onChange={e => {
if (!e.target.files || state !== LoginStateType.Idle) if (!e.target.files || state !== LoginStateType.Idle) return;
return;
for (const item of e.target.files) { for (const item of e.target.files) {
if (item.type.startsWith("image/")) { if (item.type.startsWith("image/")) {
setState(LoginStateType.Loading); setState(LoginStateType.Loading);
handleProcessImage( handleProcessImage(item, modalProps);
item,
err => (
(error.current = err),
setState(LoginStateType.Idle)
),
props.onClose
);
break; break;
} }
} }
@ -331,10 +477,9 @@ function QrModal(props: ModalProps) {
className={cl("modal-button")} className={cl("modal-button")}
disabled={state === LoginStateType.Loading} disabled={state === LoginStateType.Loading}
onClick={() => { onClick={() => {
if (state === LoginStateType.Idle) if (state === LoginStateType.Idle) setState(LoginStateType.Camera);
setState(LoginStateType.Camera);
else if (state === LoginStateType.Camera) else if (state === LoginStateType.Camera)
setState(LoginStateType.Idle); modalProps.current.exit(null);
}} }}
> >
{state === LoginStateType.Camera {state === LoginStateType.Camera

View file

@ -23,6 +23,7 @@ import {
useState, useState,
} from "@webpack/common"; } from "@webpack/common";
import { images } from "../../images";
import { cl } from ".."; import { cl } from "..";
const { Controller } = findByPropsLazy("Controller"); const { Controller } = findByPropsLazy("Controller");
@ -33,12 +34,6 @@ enum VerifyState {
NotFound, NotFound,
} }
export enum AbortStatus {
Abort,
Fail,
Success,
}
function VerifyModal({ function VerifyModal({
token, token,
onAbort, onAbort,
@ -126,7 +121,7 @@ function VerifyModal({
<> <>
<img <img
className={cl("device-image")} className={cl("device-image")}
src="https://github.com/nexpid/Themelings/raw/data/icons/images/native/img_remote_auth_succeeded.png" src={images.deviceImage.success}
key="img-success" key="img-success"
draggable={false} draggable={false}
/> />
@ -150,7 +145,7 @@ function VerifyModal({
<> <>
<img <img
className={cl("device-image")} className={cl("device-image")}
src="https://github.com/nexpid/Themelings/raw/data/icons/images/native/img_remote_auth_not_found.png" src={images.deviceImage.notFound}
key="img-not_found" key="img-not_found"
draggable={false} draggable={false}
/> />
@ -174,7 +169,7 @@ function VerifyModal({
<> <>
<img <img
className={cl("device-image")} className={cl("device-image")}
src="https://github.com/nexpid/Themelings/raw/data/icons/images/native/img_remote_auth_loaded.png" src={images.deviceImage.loading}
key="img-loaded" key="img-loaded"
draggable={false} draggable={false}
/> />
@ -187,8 +182,7 @@ function VerifyModal({
{i18n.Messages.QR_CODE_LOGIN_CONFIRM} {i18n.Messages.QR_CODE_LOGIN_CONFIRM}
</Text> </Text>
<Text variant="text-md/semibold" color="text-danger"> <Text variant="text-md/semibold" color="text-danger">
Never scan a login QR code from another user or Never scan a login QR code from another user or application.
application.
</Text> </Text>
<Button <Button
size={Button.Sizes.LARGE} size={Button.Sizes.LARGE}
@ -235,6 +229,11 @@ export default function openVerifyModal(
closeMain: () => void closeMain: () => void
) { ) {
return openModal(props => ( return openModal(props => (
<VerifyModal {...props} token={token} onAbort={onAbort} closeMain={closeMain} /> <VerifyModal
{...props}
token={token}
onAbort={onAbort}
closeMain={closeMain}
/>
)); ));
} }

View file

@ -17,7 +17,9 @@
cursor: pointer; cursor: pointer;
overflow: hidden; overflow: hidden;
transition: 200ms background-color ease-in-out, 200ms border-color ease-in-out, transition: 200ms background-color ease-in-out, 200ms border-color ease-in-out,
200ms opacity ease-in-out; 200ms opacity ease-in-out, 200ms border-width ease-in-out,
250ms width cubic-bezier(0.68, -0.6, 0.32, 1.6),
250ms height cubic-bezier(0.68, -0.6, 0.32, 1.6);
} }
.qrlogin-modal-filepaste * { .qrlogin-modal-filepaste * {
@ -34,6 +36,29 @@
cursor: unset; cursor: unset;
} }
.qrlogin-modal-filepaste-preview {
border-width: 0;
background-color: #0005;
}
.qrlogin-preview-cross {
position: absolute;
width: var(--size);
height: var(--size);
transform: translate(-50%, -50%) rotate(var(--rot));
--size: 0rem;
--rot: 0deg;
}
.qrlogin-preview-cross img {
width: var(--size);
height: var(--size);
transform-origin: bottom right;
animation: 300ms cross ease-out forwards 500ms;
opacity: 0;
}
.qrlogin-modal-button { .qrlogin-modal-button {
margin: 1rem 0; margin: 1rem 0;
width: 100%; width: 100%;
@ -84,5 +109,15 @@
-webkit-mask: var(--si-scan-qr-code) center/contain no-repeat !important; -webkit-mask: var(--si-scan-qr-code) center/contain no-repeat !important;
mask: var(--si-scan-qr-code) center/contain no-repeat !important; mask: var(--si-scan-qr-code) center/contain no-repeat !important;
--si-scan-qr-code: url("data:image/svg+xml;base64,PHN2ZyBhcmlhLWhpZGRlbj0idHJ1ZSIgcm9sZT0iaW1nIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGZpbGw9Im5vbmUiIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZmlsbD0icmVkIiBkPSJNNCA2YzAtMS4xLjktMiAyLTJoM2ExIDEgMCAwIDAgMC0ySDZhNCA0IDAgMCAwLTQgNHYzYTEgMSAwIDAgMCAyIDBWNlpNNCAxOGMwIDEuMS45IDIgMiAyaDNhMSAxIDAgMSAxIDAgMkg2YTQgNCAwIDAgMS00LTR2LTNhMSAxIDAgMSAxIDIgMHYzWk0yMCA2YTIgMiAwIDAgMC0yLTJoLTNhMSAxIDAgMSAxIDAtMmgzYTQgNCAwIDAgMSA0IDR2M2ExIDEgMCAxIDEtMiAwVjZaIiBjbGFzcz0iIj48L3BhdGg+PHBhdGggZmlsbD0icmVkIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik01IDdjMC0xLjEuOS0yIDItMmgyYTIgMiAwIDAgMSAyIDJ2MmEyIDIgMCAwIDEtMiAySDdhMiAyIDAgMCAxLTItMlY3Wm0yIDBoMnYySDdWN1pNNSAxNWMwLTEuMS45LTIgMi0yaDJhMiAyIDAgMCAxIDIgMnYyYTIgMiAwIDAgMS0yIDJIN2EyIDIgMCAwIDEtMi0ydi0yWm0yIDBoMnYySDd2LTJaTTEzIDdjMC0xLjEuOS0yIDItMmgyYTIgMiAwIDAgMSAyIDJ2MmEyIDIgMCAwIDEtMiAyaC0yYTIgMiAwIDAgMS0yLTJWN1ptMiAwaDJ2MmgtMlY3Wk0xNy4wOCAxM2ExLjUgMS41IDAgMCAwLTEuNDIgMS4wM2MtLjA5LjI1LS4zLjQ3LS41Ni40N0gxNWEyIDIgMCAwIDAtMiAyVjIwYzAgMS4xLjkgMiAyIDJoNmEyIDIgMCAwIDAgMi0ydi0zLjVhMiAyIDAgMCAwLTItMmgtLjFjLS4yNiAwLS40Ny0uMjItLjU2LS40N0ExLjUgMS41IDAgMCAwIDE4LjkyIDEzaC0xLjg0Wk0yMCAxOGEyIDIgMCAxIDEtNCAwIDIgMiAwIDAgMSA0IDBaIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGNsYXNzPSIiPjwvcGF0aD48L3N2Zz4=") --si-scan-qr-code: url("data:image/svg+xml;base64,PHN2ZyBhcmlhLWhpZGRlbj0idHJ1ZSIgcm9sZT0iaW1nIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGZpbGw9Im5vbmUiIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZmlsbD0icmVkIiBkPSJNNCA2YzAtMS4xLjktMiAyLTJoM2ExIDEgMCAwIDAgMC0ySDZhNCA0IDAgMCAwLTQgNHYzYTEgMSAwIDAgMCAyIDBWNlpNNCAxOGMwIDEuMS45IDIgMiAyaDNhMSAxIDAgMSAxIDAgMkg2YTQgNCAwIDAgMS00LTR2LTNhMSAxIDAgMSAxIDIgMHYzWk0yMCA2YTIgMiAwIDAgMC0yLTJoLTNhMSAxIDAgMSAxIDAtMmgzYTQgNCAwIDAgMSA0IDR2M2ExIDEgMCAxIDEtMiAwVjZaIiBjbGFzcz0iIj48L3BhdGg+PHBhdGggZmlsbD0icmVkIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik01IDdjMC0xLjEuOS0yIDItMmgyYTIgMiAwIDAgMSAyIDJ2MmEyIDIgMCAwIDEtMiAySDdhMiAyIDAgMCAxLTItMlY3Wm0yIDBoMnYySDdWN1pNNSAxNWMwLTEuMS45LTIgMi0yaDJhMiAyIDAgMCAxIDIgMnYyYTIgMiAwIDAgMS0yIDJIN2EyIDIgMCAwIDEtMi0ydi0yWm0yIDBoMnYySDd2LTJaTTEzIDdjMC0xLjEuOS0yIDItMmgyYTIgMiAwIDAgMSAyIDJ2MmEyIDIgMCAwIDEtMiAyaC0yYTIgMiAwIDAgMS0yLTJWN1ptMiAwaDJ2MmgtMlY3Wk0xNy4wOCAxM2ExLjUgMS41IDAgMCAwLTEuNDIgMS4wM2MtLjA5LjI1LS4zLjQ3LS41Ni40N0gxNWEyIDIgMCAwIDAtMiAyVjIwYzAgMS4xLjkgMiAyIDJoNmEyIDIgMCAwIDAgMi0ydi0zLjVhMiAyIDAgMCAwLTItMmgtLjFjLS4yNiAwLS40Ny0uMjItLjU2LS40N0ExLjUgMS41IDAgMCAwIDE4LjkyIDEzaC0xLjg0Wk0yMCAxOGEyIDIgMCAxIDEtNCAwIDIgMiAwIDAgMSA0IDBaIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGNsYXNzPSIiPjwvcGF0aD48L3N2Zz4=");
} }
@keyframes cross {
0% {
transform: scale(1.25);
}
100% {
opacity: 1;
}
}