Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
194 lines
6.0 KiB
Svelte
194 lines
6.0 KiB
Svelte
<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>
|