261 lines
6.7 KiB
Svelte
261 lines
6.7 KiB
Svelte
<script lang="ts">
|
|
import { Dialog } from "bits-ui";
|
|
import { untrack } from "svelte";
|
|
import { ApiError } from "#lib/api/client.js";
|
|
import {
|
|
cancelLogin,
|
|
pollQrLogin,
|
|
startLogin,
|
|
startQrLogin,
|
|
submitLoginCode,
|
|
submitLoginPassword,
|
|
} from "#lib/api/endpoints.js";
|
|
import type { LoginState } from "#lib/api/types.js";
|
|
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 { t } from "#lib/i18n/locale.svelte.js";
|
|
import type { MessageKey } from "#lib/i18n/messages.js";
|
|
import { accounts } from "#lib/stores/accounts.svelte.js";
|
|
import { toasts } from "#lib/stores/toasts.svelte.js";
|
|
|
|
type Step = "qr" | "phone" | "code" | "password";
|
|
|
|
let { open = $bindable(false) }: { open?: boolean } = $props();
|
|
|
|
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;
|
|
|
|
function hint(current: Step): string {
|
|
return t(`login.hint.${current}`);
|
|
}
|
|
|
|
const filled = $derived.by(() => {
|
|
if (step === "phone") {
|
|
return phone.trim().length > 0;
|
|
}
|
|
if (step === "code") {
|
|
return code.trim().length > 0;
|
|
}
|
|
return password.length > 0;
|
|
});
|
|
|
|
async function apply(state: LoginState) {
|
|
if (state.stage !== "done") {
|
|
loginId = state.login_id;
|
|
step = state.stage;
|
|
return;
|
|
}
|
|
loginId = "";
|
|
await accounts.load();
|
|
if (state.account) {
|
|
accounts.select(state.account.account_id);
|
|
}
|
|
toasts.success(t("login.added"));
|
|
open = false;
|
|
}
|
|
|
|
function fail(error: unknown, fallback: MessageKey) {
|
|
toasts.error(error instanceof ApiError ? error.detail : t(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 ?? "";
|
|
// biome-ignore lint/performance/noAwaitInLoops: qr login is polled step by step
|
|
state = await pollQrLogin(state.login_id);
|
|
}
|
|
if (mine === attempt) {
|
|
await apply(state);
|
|
}
|
|
} catch (error) {
|
|
if (mine === attempt) {
|
|
fail(error, "login.qrFailed");
|
|
}
|
|
}
|
|
}
|
|
|
|
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) {
|
|
return;
|
|
}
|
|
busy = true;
|
|
try {
|
|
await apply(await next());
|
|
} catch (error) {
|
|
fail(error, "login.failed");
|
|
} finally {
|
|
busy = false;
|
|
}
|
|
}
|
|
|
|
function onOpenChange(value: boolean) {
|
|
if (!value) {
|
|
drop();
|
|
}
|
|
}
|
|
|
|
$effect(() => {
|
|
if (open) {
|
|
untrack(begin);
|
|
}
|
|
});
|
|
</script>
|
|
|
|
<Dialog.Root {onOpenChange} bind:open>
|
|
<Dialog.Portal>
|
|
<Dialog.Overlay class="dialog-overlay" />
|
|
<Dialog.Content class="dialog-content">
|
|
<header class="dialog-head">
|
|
<Dialog.Title class="dialog-title">{t("login.title")}</Dialog.Title>
|
|
<Dialog.Close aria-label={t("common.close")} class="dialog-close">
|
|
<Icon name="close" size="1.25rem" />
|
|
</Dialog.Close>
|
|
</header>
|
|
{#if step === "qr"}
|
|
<div class="dialog-body">
|
|
<p class="hint">{hint("qr")}</p>
|
|
<div class="qr-slot">
|
|
{#if qrUrl}
|
|
<QrCode label={t("login.qrLabel")} value={qrUrl} />
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
<div class="dialog-actions">
|
|
<Button onclick={usePhone} pill variant="text">
|
|
{t("login.byPhone")}
|
|
</Button>
|
|
</div>
|
|
{:else}
|
|
<form onsubmit={submit}>
|
|
<div class="dialog-body">
|
|
<p class="hint">{hint(step)}</p>
|
|
{#if step === "phone"}
|
|
<div class="input-group">
|
|
<input
|
|
autocomplete="tel"
|
|
class="form-control"
|
|
id="login-phone"
|
|
placeholder="+7 999 123-45-67"
|
|
type="tel"
|
|
bind:value={phone}
|
|
>
|
|
<label for="login-phone">{t("login.phone")}</label>
|
|
</div>
|
|
{:else if step === "code"}
|
|
<div class="input-group">
|
|
<input
|
|
autocomplete="one-time-code"
|
|
class="form-control"
|
|
id="login-code"
|
|
inputmode="numeric"
|
|
placeholder="12345"
|
|
type="text"
|
|
bind:value={code}
|
|
>
|
|
<label for="login-code">{t("login.code")}</label>
|
|
</div>
|
|
{:else}
|
|
<div class="input-group">
|
|
<input
|
|
autocomplete="current-password"
|
|
class="form-control"
|
|
id="login-password"
|
|
placeholder={t("login.passwordPlaceholder")}
|
|
type="password"
|
|
bind:value={password}
|
|
>
|
|
<label for="login-password">{t("login.password")}</label>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
<div class="dialog-actions">
|
|
{#if step === "phone"}
|
|
<Button onclick={useQr} pill variant="text">
|
|
{t("login.qr")}
|
|
</Button>
|
|
{/if}
|
|
<Button
|
|
disabled={busy || !filled}
|
|
loading={busy}
|
|
pill
|
|
type="submit"
|
|
>
|
|
{step === "phone" ? t("login.sendCode") : t("login.continue")}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
{/if}
|
|
</Dialog.Content>
|
|
</Dialog.Portal>
|
|
</Dialog.Root>
|
|
|
|
<style lang="scss">
|
|
.hint {
|
|
margin: 0 0 1.5rem;
|
|
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>
|