157 lines
4.9 KiB
Svelte
157 lines
4.9 KiB
Svelte
<script lang="ts">
|
|||
|
|
import { goto } from "$app/navigation";
|
||
|
|
import { page } from "$app/state";
|
||
|
|
import { api, ApiError } from "$lib/api";
|
||
|
|
import { trackEvent } from "$lib/analytics";
|
||
|
|
import { apiFormError, fieldDescribedBy, fieldInvalid, parseBodyErrorCodes } from "$lib/api-form-error";
|
||
|
|
import { i18n } from "$lib/i18n";
|
||
|
|
import { safeInternalNext } from "$lib/safe-next";
|
||
|
|
import {
|
||
|
|
Alert,
|
||
|
|
AlertDescription,
|
||
|
|
Button,
|
||
|
|
Card,
|
||
|
|
CardContent,
|
||
|
|
CardDescription,
|
||
|
|
CardHeader,
|
||
|
|
CardTitle,
|
||
|
|
Input,
|
||
|
|
Label
|
||
|
|
} from "$lib/components/ui";
|
||
|
|
|
||
|
|
const LOGIN_FIELD_HINTS = {
|
||
|
|
// Prefer stable codes (locale-safe). English needles are last-resort only.
|
||
|
|
email: ["invalid_credentials", "email", "credentials"],
|
||
|
|
password: ["invalid_credentials", "password_not_set", "password", "credentials"]
|
||
|
|
} as const;
|
||
|
|
|
||
|
|
let email = $state("");
|
||
|
|
let password = $state("");
|
||
|
|
let error = $state("");
|
||
|
|
let fieldErrors = $state<Record<string, string>>({});
|
||
|
|
let loading = $state(false);
|
||
|
|
let needsPasswordInvite = $state(false);
|
||
|
|
const errorId = "login-form-error";
|
||
|
|
|
||
|
|
function apiErrorCode(err: unknown): string {
|
||
|
|
if (!(err instanceof ApiError)) return "";
|
||
|
|
return parseBodyErrorCodes(err.body)[0] ?? "";
|
||
|
|
}
|
||
|
|
|
||
|
|
async function onSubmit(event: Event) {
|
||
|
|
event.preventDefault();
|
||
|
|
error = "";
|
||
|
|
fieldErrors = {};
|
||
|
|
needsPasswordInvite = false;
|
||
|
|
loading = true;
|
||
|
|
try {
|
||
|
|
await api("/api/auth/login", {
|
||
|
|
method: "POST",
|
||
|
|
body: { email, password }
|
||
|
|
});
|
||
|
|
trackEvent("login", { method: "email" });
|
||
|
|
await goto(safeInternalNext(page.url.searchParams.get("next"), "/dashboard"));
|
||
|
|
} catch (err) {
|
||
|
|
if (apiErrorCode(err) === "password_not_set") {
|
||
|
|
needsPasswordInvite = true;
|
||
|
|
const result = apiFormError(err, i18n.t("auth.login.passwordNotSet"));
|
||
|
|
error = result.message;
|
||
|
|
fieldErrors = {};
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const result = apiFormError(err, i18n.t("auth.login.failed"), LOGIN_FIELD_HINTS);
|
||
|
|
error = result.message;
|
||
|
|
fieldErrors = result.fields;
|
||
|
|
} finally {
|
||
|
|
loading = false;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
</script>
|
||
|
|
|
||
|
|
<Card>
|
||
|
|
<CardHeader>
|
||
|
|
<CardTitle level={1}>{i18n.t("auth.login.title")}</CardTitle>
|
||
|
|
<CardDescription>{i18n.t("auth.login.description")}</CardDescription>
|
||
|
|
</CardHeader>
|
||
|
|
<CardContent>
|
||
|
|
<form
|
||
|
|
method="post"
|
||
|
|
class="space-y-4"
|
||
|
|
onsubmit={onSubmit}
|
||
|
|
aria-busy={loading}
|
||
|
|
aria-describedby={error ? errorId : undefined}
|
||
|
|
>
|
||
|
|
{#if error}
|
||
|
|
<Alert variant="destructive">
|
||
|
|
<AlertDescription id={errorId}>{error}</AlertDescription>
|
||
|
|
</Alert>
|
||
|
|
{/if}
|
||
|
|
|
||
|
|
{#if needsPasswordInvite}
|
||
|
|
<div class="space-y-2 rounded-md border bg-muted/40 px-3 py-3 text-sm text-text-muted">
|
||
|
|
<p>
|
||
|
|
<strong class="text-text">{i18n.t("auth.login.setPasswordFirstTitle")}</strong>
|
||
|
|
{i18n.t("auth.login.setPasswordFirstBody", {
|
||
|
|
email: email || i18n.t("auth.login.yourEmail")
|
||
|
|
})}
|
||
|
|
</p>
|
||
|
|
<p>
|
||
|
|
<a href="/accept-invite" class="font-medium text-link hover:underline"
|
||
|
|
>{i18n.t("auth.login.haveToken")}</a
|
||
|
|
>
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
{/if}
|
||
|
|
|
||
|
|
<div class="space-y-2">
|
||
|
|
<Label for="email">{i18n.t("common.email")}</Label>
|
||
|
|
<Input
|
||
|
|
id="email"
|
||
|
|
type="email"
|
||
|
|
name="email"
|
||
|
|
autocomplete="email"
|
||
|
|
required
|
||
|
|
aria-invalid={fieldInvalid(fieldErrors, "email") ?? (error && !Object.keys(fieldErrors).length ? "true" : undefined)}
|
||
|
|
aria-describedby={fieldDescribedBy(fieldErrors, "email", errorId) ?? (error && !Object.keys(fieldErrors).length ? errorId : undefined)}
|
||
|
|
bind:value={email}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="space-y-2">
|
||
|
|
<div class="flex items-center justify-between gap-2">
|
||
|
|
<Label for="password">{i18n.t("common.password")}</Label>
|
||
|
|
<a href="/forgot-password" class="text-xs font-medium text-link hover:underline">
|
||
|
|
{i18n.t("auth.login.forgotPassword")}
|
||
|
|
</a>
|
||
|
|
</div>
|
||
|
|
<Input
|
||
|
|
id="password"
|
||
|
|
type="password"
|
||
|
|
name="password"
|
||
|
|
autocomplete="current-password"
|
||
|
|
required
|
||
|
|
aria-invalid={fieldInvalid(fieldErrors, "password") ?? (error && !Object.keys(fieldErrors).length ? "true" : undefined)}
|
||
|
|
aria-describedby={fieldDescribedBy(fieldErrors, "password", errorId) ?? (error && !Object.keys(fieldErrors).length ? errorId : undefined)}
|
||
|
|
bind:value={password}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<Button type="submit" class="w-full shadow-sm" loading={loading}>
|
||
|
|
{loading ? i18n.t("auth.login.submitting") : i18n.t("auth.login.submit")}
|
||
|
|
</Button>
|
||
|
|
</form>
|
||
|
|
|
||
|
|
<p class="mt-5 text-center text-sm text-text-muted">
|
||
|
|
{i18n.t("auth.login.noAccount")}
|
||
|
|
<a href="/register" class="font-medium text-link hover:underline">{i18n.t("auth.login.createCompany")}</a>
|
||
|
|
</p>
|
||
|
|
<p class="mt-1.5 text-center text-sm text-text-muted">
|
||
|
|
{i18n.t("auth.login.haveInvite")}
|
||
|
|
<a href="/accept-invite" class="font-medium text-link hover:underline">{i18n.t("auth.login.acceptInvite")}</a>
|
||
|
|
</p>
|
||
|
|
<p class="mt-1.5 text-center text-sm text-text-muted">
|
||
|
|
<a href="/pricing" class="font-medium text-link hover:underline">{i18n.t("common.viewPricing")}</a>
|
||
|
|
</p>
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|