Files
descrybe/apps/web/src/routes/reset-password/+page.svelte
T

194 lines
6.0 KiB
Svelte
Raw Normal View History

<script lang="ts">
import { onMount } from "svelte";
import { page } from "$app/state";
import { api, ApiError } from "$lib/api";
import { apiFormError, fieldDescribedBy, fieldInvalid } from "$lib/api-form-error";
import { i18n } from "$lib/i18n";
import {
Alert,
AlertDescription,
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Input,
Label
} from "$lib/components/ui";
function tokenFromHash(hash: string): string {
const raw = hash.startsWith("#") ? hash.slice(1) : hash;
const params = new URLSearchParams(raw);
return (params.get("token") ?? "").trim();
}
let token = $state("");
/** True when the token arrived via link; never show it in a visible input. */
let tokenFromLink = $state(false);
/** Fragment tokens are client-only; wait for mount before showing missing-token UI. */
let hydrated = $state(false);
let password = $state("");
let passwordConfirm = $state("");
let error = $state("");
let fieldErrors = $state<Record<string, string>>({});
let loading = $state(false);
let done = $state(false);
const errorId = "reset-password-form-error";
const missingErrorId = "reset-password-missing-error";
const passwordHintId = "reset-password-hint";
onMount(() => {
const fromHash = tokenFromHash(window.location.hash);
const fromQuery = (new URL(window.location.href).searchParams.get("token") ?? "").trim();
const fromSsrQuery = (page.url.searchParams.get("token") ?? "").trim();
const resolved = fromHash || fromQuery || fromSsrQuery;
if (resolved) {
token = resolved;
tokenFromLink = true;
}
hydrated = true;
if (!fromHash && !fromQuery && !fromSsrQuery) return;
const cleaned = new URL(window.location.href);
cleaned.searchParams.delete("token");
cleaned.hash = "";
history.replaceState(history.state, "", cleaned.pathname + cleaned.search);
});
async function onSubmit(event: Event) {
event.preventDefault();
error = "";
fieldErrors = {};
if (!token.trim()) {
error = i18n.t("auth.reset.tokenMissing");
return;
}
if (password !== passwordConfirm) {
error = i18n.t("auth.reset.passwordMismatch");
fieldErrors = { password: i18n.t("auth.reset.passwordMismatch") };
return;
}
loading = true;
try {
await api("/api/auth/reset-password", {
method: "POST",
body: { token: token.trim(), password }
});
done = true;
} catch (err) {
const result = apiFormError(err, i18n.t("auth.reset.failed"), {
password: ["password_too_short", "password", "required"],
token: ["invalid or expired", "token"]
});
error = result.message;
fieldErrors = result.fields;
if (err instanceof ApiError && /invalid or expired/i.test(err.message)) {
error = i18n.t("auth.reset.tokenInvalid");
}
} finally {
loading = false;
}
}
</script>
<Card>
{#if !hydrated}
<CardHeader>
<CardTitle level={1}>{i18n.t("auth.reset.title")}</CardTitle>
<CardDescription>{i18n.t("auth.reset.description")}</CardDescription>
</CardHeader>
<CardContent>
<p class="text-sm text-text-muted" aria-live="polite">{i18n.t("common.loading")}</p>
</CardContent>
{:else if !tokenFromLink}
<CardHeader>
<CardTitle level={1}>{i18n.t("auth.reset.missingTitle")}</CardTitle>
<CardDescription>{i18n.t("auth.reset.missingDescription")}</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<Alert variant="destructive">
<AlertDescription id={missingErrorId}>{i18n.t("auth.reset.tokenMissing")}</AlertDescription>
</Alert>
<p class="text-center text-sm text-text-muted">
<a href="/forgot-password" class="font-medium text-link hover:underline"
>{i18n.t("auth.reset.requestNewLink")}</a
>
</p>
<p class="text-center text-sm text-text-muted">
<a href="/login" class="font-medium text-link hover:underline">{i18n.t("auth.reset.goToSignIn")}</a>
</p>
</CardContent>
{:else}
<CardHeader>
<CardTitle level={1}>
{done ? i18n.t("auth.reset.doneTitle") : i18n.t("auth.reset.title")}
</CardTitle>
<CardDescription>
{done ? i18n.t("auth.reset.doneDescription") : i18n.t("auth.reset.description")}
</CardDescription>
</CardHeader>
<CardContent>
{#if done}
<p class="text-center text-sm text-text-muted">
<a href="/login" class="font-medium text-link hover:underline">{i18n.t("auth.reset.goToSignIn")}</a>
</p>
{:else}
<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}
<p class="rounded-md border bg-muted/40 px-3 py-2 text-xs text-text-muted" role="note">
{i18n.t("auth.reset.linkRecognized")}
</p>
<div class="space-y-2">
<Label for="password">{i18n.t("common.password")}</Label>
<Input
id="password"
type="password"
name="password"
autocomplete="new-password"
required
minlength={8}
aria-invalid={fieldInvalid(fieldErrors, "password")}
aria-describedby={fieldDescribedBy(fieldErrors, "password", errorId) ?? passwordHintId}
bind:value={password}
/>
<p id={passwordHintId} class="text-xs text-text-muted">{i18n.t("auth.reset.passwordHint")}</p>
</div>
<div class="space-y-2">
<Label for="password_confirm">{i18n.t("auth.reset.passwordConfirm")}</Label>
<Input
id="password_confirm"
type="password"
name="password_confirm"
autocomplete="new-password"
required
minlength={8}
bind:value={passwordConfirm}
/>
</div>
<Button type="submit" class="w-full shadow-sm" loading={loading}>
{loading ? i18n.t("auth.reset.submitting") : i18n.t("auth.reset.submit")}
</Button>
</form>
<p class="mt-5 text-center text-sm text-text-muted">
<a href="/login" class="font-medium text-link hover:underline">{i18n.t("auth.reset.goToSignIn")}</a>
</p>
{/if}
</CardContent>
{/if}
</Card>