feat(api,frontend): add qr code login

This commit is contained in:
hh
2026-08-06 00:23:38 +02:00
parent 9c265af3d3
commit e887dfc5ce
9 changed files with 315 additions and 80 deletions
+3 -1
View File
@@ -1,6 +1,5 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "frontend",
@@ -8,6 +7,7 @@
"bits-ui": "^2.18.1",
"lottie-web": "^5.13.0",
"pako": "^2.1.0",
"uqr": "^0.1.3",
},
"devDependencies": {
"@biomejs/biome": "2.4.15",
@@ -388,6 +388,8 @@
"undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
"uqr": ["uqr@0.1.3", "", {}, "sha512-0rjE8iEJe4YmT9TOhwsZtqCMRLc5DXZUI2UEYUUg63ikBkqqE5EYWaI0etFe/5KUcmcYwLih2RND1kq+hrUJXA=="],
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
"vite": ["vite@8.0.14", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.15", "rolldown": "1.0.2", "tinyglobby": "^0.2.16" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.18", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw=="],
+2 -1
View File
@@ -34,6 +34,7 @@
"dependencies": {
"bits-ui": "^2.18.1",
"lottie-web": "^5.13.0",
"pako": "^2.1.0"
"pako": "^2.1.0",
"uqr": "^0.1.3"
}
}
+8
View File
@@ -53,6 +53,14 @@ export function startLogin(phone: string): Promise<LoginState> {
});
}
export function startQrLogin(): Promise<LoginState> {
return request<LoginState>("/accounts/login/qr", { method: "POST" });
}
export function pollQrLogin(loginId: string): Promise<LoginState> {
return request<LoginState>(`/accounts/login/${loginId}/qr`);
}
export function submitLoginCode(
loginId: string,
code: string
+2 -1
View File
@@ -22,11 +22,12 @@ export interface Account {
tg_user_id: number | null;
}
export type LoginStage = "code" | "password" | "done";
export type LoginStage = "code" | "qr" | "password" | "done";
export interface LoginState {
account: Account | null;
login_id: string;
qr_url: string | null;
stage: LoginStage;
}
@@ -1,30 +1,37 @@
<script lang="ts">
import { Dialog } from "bits-ui";
import { untrack } from "svelte";
import { ApiError } from "$lib/api/client";
import {
cancelLogin,
pollQrLogin,
startLogin,
startQrLogin,
submitLoginCode,
submitLoginPassword,
} from "$lib/api/endpoints";
import type { LoginState } from "$lib/api/types";
import Button from "$lib/components/ui/Button.svelte";
import Icon from "$lib/components/ui/Icon.svelte";
import QrCode from "$lib/components/ui/QrCode.svelte";
import { accounts } from "$lib/stores/accounts.svelte";
import { toasts } from "$lib/stores/toasts.svelte";
type Step = "phone" | "code" | "password";
type Step = "qr" | "phone" | "code" | "password";
let { open = $bindable(false) }: { open?: boolean } = $props();
let step = $state<Step>("phone");
let step = $state<Step>("qr");
let loginId = $state("");
let qrUrl = $state("");
let phone = $state("");
let code = $state("");
let password = $state("");
let busy = $state(false);
let attempt = 0;
const hints: Record<Step, string> = {
qr: "Telegram → Настройки → Устройства → Подключить устройство, и наведите камеру на код.",
phone: "Номер телефона в международном формате, например +79991234567.",
code: "Код отправлен в Telegram на этот номер.",
password: "Аккаунт защищён двухэтапной аутентификацией.",
@@ -40,24 +47,6 @@
return password.length > 0;
});
function reset() {
step = "phone";
loginId = "";
phone = "";
code = "";
password = "";
}
function next(): Promise<LoginState> {
if (step === "phone") {
return startLogin(phone.trim());
}
if (step === "code") {
return submitLoginCode(loginId, code.trim());
}
return submitLoginPassword(loginId, password);
}
async function apply(state: LoginState) {
if (state.stage !== "done") {
loginId = state.login_id;
@@ -73,6 +62,69 @@
open = false;
}
function fail(error: unknown, fallback: string) {
toasts.error(error instanceof ApiError ? error.detail : fallback);
}
function drop() {
attempt += 1;
if (loginId) {
cancelLogin(loginId).catch(() => undefined);
loginId = "";
}
}
async function watchQr() {
const mine = attempt;
try {
let state = await startQrLogin();
while (mine === attempt && state.stage === "qr") {
loginId = state.login_id;
qrUrl = state.qr_url ?? "";
state = await pollQrLogin(state.login_id);
}
if (mine === attempt) {
await apply(state);
}
} catch (error) {
if (mine === attempt) {
fail(error, "Не удалось получить QR-код");
}
}
}
function useQr() {
drop();
step = "qr";
qrUrl = "";
watchQr();
}
function usePhone() {
drop();
step = "phone";
qrUrl = "";
}
function begin() {
attempt += 1;
loginId = "";
phone = "";
code = "";
password = "";
useQr();
}
function next(): Promise<LoginState> {
if (step === "phone") {
return startLogin(phone.trim());
}
if (step === "code") {
return submitLoginCode(loginId, code.trim());
}
return submitLoginPassword(loginId, password);
}
async function submit(event: SubmitEvent) {
event.preventDefault();
if (busy || !filled) {
@@ -82,20 +134,23 @@
try {
await apply(await next());
} catch (error) {
toasts.error(
error instanceof ApiError ? error.detail : "Не удалось войти"
);
fail(error, "Не удалось войти");
} finally {
busy = false;
}
}
function onOpenChange(value: boolean) {
if (!value && loginId) {
cancelLogin(loginId).catch(() => undefined);
if (!value) {
drop();
}
reset();
}
$effect(() => {
if (open) {
untrack(begin);
}
});
</script>
<Dialog.Root bind:open {onOpenChange}>
@@ -108,54 +163,78 @@
<Icon name="close" size="1.25rem" />
</Dialog.Close>
</header>
<form onsubmit={submit}>
{#if step === "qr"}
<div class="dialog-body">
<p class="hint">{hints[step]}</p>
{#if step === "phone"}
<div class="input-group">
<input
id="login-phone"
class="form-control"
type="tel"
autocomplete="tel"
placeholder="+7 999 123-45-67"
bind:value={phone}
>
<label for="login-phone">Номер телефона</label>
</div>
{:else if step === "code"}
<div class="input-group">
<input
id="login-code"
class="form-control"
type="text"
inputmode="numeric"
autocomplete="one-time-code"
placeholder="12345"
bind:value={code}
>
<label for="login-code">Код подтверждения</label>
</div>
{:else}
<div class="input-group">
<input
id="login-password"
class="form-control"
type="password"
autocomplete="current-password"
placeholder="Пароль"
bind:value={password}
>
<label for="login-password">Облачный пароль</label>
</div>
{/if}
<p class="hint">{hints.qr}</p>
<div class="qr-slot">
{#if qrUrl}
<QrCode value={qrUrl} label="QR-код для входа в Telegram" />
{/if}
</div>
</div>
<div class="dialog-actions">
<Button type="submit" pill loading={busy} disabled={busy || !filled}>
{step === "phone" ? "Отправить код" : "Продолжить"}
<Button variant="text" pill onclick={usePhone}>
Войти по номеру телефона
</Button>
</div>
</form>
{:else}
<form onsubmit={submit}>
<div class="dialog-body">
<p class="hint">{hints[step]}</p>
{#if step === "phone"}
<div class="input-group">
<input
id="login-phone"
class="form-control"
type="tel"
autocomplete="tel"
placeholder="+7 999 123-45-67"
bind:value={phone}
>
<label for="login-phone">Номер телефона</label>
</div>
{:else if step === "code"}
<div class="input-group">
<input
id="login-code"
class="form-control"
type="text"
inputmode="numeric"
autocomplete="one-time-code"
placeholder="12345"
bind:value={code}
>
<label for="login-code">Код подтверждения</label>
</div>
{:else}
<div class="input-group">
<input
id="login-password"
class="form-control"
type="password"
autocomplete="current-password"
placeholder="Пароль"
bind:value={password}
>
<label for="login-password">Облачный пароль</label>
</div>
{/if}
</div>
<div class="dialog-actions">
{#if step === "phone"}
<Button variant="text" pill onclick={useQr}>QR-код</Button>
{/if}
<Button
type="submit"
pill
loading={busy}
disabled={busy || !filled}
>
{step === "phone" ? "Отправить код" : "Продолжить"}
</Button>
</div>
</form>
{/if}
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
@@ -166,4 +245,14 @@
font-size: 0.9375rem;
color: var(--color-text-secondary);
}
.qr-slot {
display: grid;
place-items: center;
width: min(15rem, 100%);
aspect-ratio: 1;
margin: 0 auto;
background: var(--color-background-secondary);
border-radius: 0.75rem;
}
</style>
@@ -0,0 +1,40 @@
<script lang="ts">
import { encode } from "uqr";
const QUIET_ZONE = 2;
let { value, label }: { value: string; label: string } = $props();
const code = $derived.by(() => {
const qr = encode(value, { ecc: "M", border: 0 });
const path = qr.data
.flatMap((row, y) =>
row.flatMap((filled, x) => (filled ? [`M${x} ${y}h1v1h-1z`] : []))
)
.join("");
return { extent: qr.size + QUIET_ZONE * 2, path };
});
</script>
<svg
class="qr"
viewBox="0 0 {code.extent} {code.extent}"
role="img"
aria-label={label}
>
<rect width={code.extent} height={code.extent} fill="var(--qr-bg, #fff)" />
<path
d={code.path}
fill="var(--qr-fg, #000)"
transform="translate({QUIET_ZONE} {QUIET_ZONE})"
/>
</svg>
<style lang="scss">
.qr {
display: block;
width: 100%;
height: auto;
border-radius: 0.75rem;
}
</style>