Files
beavergram/frontend/src/lib/components/settings/AddAccountDialog.svelte
T

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>