Merge pull request #76 from emailerfacu-spec/reset-contraseña

Reset contraseña
This commit is contained in:
emailerfacu-spec
2026-01-15 17:46:36 -03:00
committed by GitHub
20 changed files with 674 additions and 1 deletions

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import EllipsisIcon from "@lucide/svelte/icons/ellipsis";
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef, type WithoutChildren } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: WithoutChildren<WithElementRef<HTMLAttributes<HTMLSpanElement>>> = $props();
</script>
<span
bind:this={ref}
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
class={cn("flex size-9 items-center justify-center", className)}
{...restProps}
>
<EllipsisIcon class="size-4" />
<span class="sr-only">More</span>
</span>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLLiAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLLiAttributes> = $props();
</script>
<li
bind:this={ref}
data-slot="breadcrumb-item"
class={cn("inline-flex items-center gap-1.5", className)}
{...restProps}
>
{@render children?.()}
</li>

View File

@@ -0,0 +1,31 @@
<script lang="ts">
import type { HTMLAnchorAttributes } from "svelte/elements";
import type { Snippet } from "svelte";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
href = undefined,
child,
children,
...restProps
}: WithElementRef<HTMLAnchorAttributes> & {
child?: Snippet<[{ props: HTMLAnchorAttributes }]>;
} = $props();
const attrs = $derived({
"data-slot": "breadcrumb-link",
class: cn("hover:text-foreground transition-colors", className),
href,
...restProps,
});
</script>
{#if child}
{@render child({ props: attrs })}
{:else}
<a bind:this={ref} {...attrs}>
{@render children?.()}
</a>
{/if}

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import type { HTMLOlAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLOlAttributes> = $props();
</script>
<ol
bind:this={ref}
data-slot="breadcrumb-list"
class={cn(
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
className
)}
{...restProps}
>
{@render children?.()}
</ol>

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLSpanElement>> = $props();
</script>
<span
bind:this={ref}
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
class={cn("text-foreground font-normal", className)}
{...restProps}
>
{@render children?.()}
</span>

View File

@@ -0,0 +1,27 @@
<script lang="ts">
import ChevronRightIcon from "@lucide/svelte/icons/chevron-right";
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLLiAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLLiAttributes> = $props();
</script>
<li
bind:this={ref}
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
class={cn("[&>svg]:size-3.5", className)}
{...restProps}
>
{#if children}
{@render children?.()}
{:else}
<ChevronRightIcon />
{/if}
</li>

View File

@@ -0,0 +1,21 @@
<script lang="ts">
import type { WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
</script>
<nav
bind:this={ref}
data-slot="breadcrumb"
class={className}
aria-label="breadcrumb"
{...restProps}
>
{@render children?.()}
</nav>

View File

@@ -0,0 +1,25 @@
import Root from "./breadcrumb.svelte";
import Ellipsis from "./breadcrumb-ellipsis.svelte";
import Item from "./breadcrumb-item.svelte";
import Separator from "./breadcrumb-separator.svelte";
import Link from "./breadcrumb-link.svelte";
import List from "./breadcrumb-list.svelte";
import Page from "./breadcrumb-page.svelte";
export {
Root,
Ellipsis,
Item,
Separator,
Link,
List,
Page,
//
Root as Breadcrumb,
Ellipsis as BreadcrumbEllipsis,
Item as BreadcrumbItem,
Separator as BreadcrumbSeparator,
Link as BreadcrumbLink,
List as BreadcrumbList,
Page as BreadcrumbPage,
};

View File

@@ -0,0 +1,15 @@
import Root from "./input-otp.svelte";
import Group from "./input-otp-group.svelte";
import Slot from "./input-otp-slot.svelte";
import Separator from "./input-otp-separator.svelte";
export {
Root,
Group,
Slot,
Separator,
Root as InputOTP,
Group as InputOTPGroup,
Slot as InputOTPSlot,
Separator as InputOTPSeparator,
};

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="input-otp-group"
class={cn("flex items-center", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,19 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import type { WithElementRef } from "$lib/utils.js";
import MinusIcon from "@lucide/svelte/icons/minus";
let {
ref = $bindable(null),
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div bind:this={ref} data-slot="input-otp-separator" role="separator" {...restProps}>
{#if children}
{@render children?.()}
{:else}
<MinusIcon />
{/if}
</div>

View File

@@ -0,0 +1,31 @@
<script lang="ts">
import { PinInput as InputOTPPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
cell,
class: className,
...restProps
}: InputOTPPrimitive.CellProps = $props();
</script>
<InputOTPPrimitive.Cell
{cell}
bind:ref
data-slot="input-otp-slot"
class={cn(
"border-input aria-invalid:border-destructive dark:bg-input/30 relative flex size-9 items-center justify-center border-y border-e text-sm transition-all outline-none first:rounded-s-md first:border-s last:rounded-e-md",
cell.isActive &&
"border-ring ring-ring/50 aria-invalid:border-destructive dark:aria-invalid:ring-destructive/40 aria-invalid:ring-destructive/20 ring-offset-background z-10 ring-[3px]",
className
)}
{...restProps}
>
{cell.char}
{#if cell.hasFakeCaret}
<div class="pointer-events-none absolute inset-0 flex items-center justify-center">
<div class="animate-caret-blink bg-foreground h-4 w-px duration-1000"></div>
</div>
{/if}
</InputOTPPrimitive.Cell>

View File

@@ -0,0 +1,22 @@
<script lang="ts">
import { PinInput as InputOTPPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
value = $bindable(""),
...restProps
}: InputOTPPrimitive.RootProps = $props();
</script>
<InputOTPPrimitive.Root
bind:ref
bind:value
data-slot="input-otp"
class={cn(
"flex items-center gap-2 has-disabled:opacity-50 [&_input]:disabled:cursor-not-allowed",
className
)}
{...restProps}
/>

View File

@@ -36,7 +36,7 @@
<Field>
<div class="flex items-center">
<FieldLabel for="password-{id}">Contraseña</FieldLabel>
<a href="##" class="ml-auto inline-block text-sm underline">
<a href="/password-reset" class="ml-auto inline-block text-sm underline">
Te Olvidaste la contraseña?
</a>
</div>

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import { goto, replaceState } from '$app/navigation';
import Card from '@/components/ui/card/card.svelte';
import { Content } from '@/components/ui/card';
import { sesionStore } from '@/stores/usuario';
@@ -11,6 +12,9 @@
import { fade, slide } from 'svelte/transition';
import { getPosts } from '@/hooks/getPosts';
import Spinner from '@/components/ui/spinner/spinner.svelte';
import { page } from '$app/state';
import Dialog from '@/components/ui/dialog/dialog.svelte';
import DialogContent from '@/components/ui/dialog/dialog-content.svelte';
$effect(() => {
resetPosts();
@@ -33,8 +37,23 @@
);
postAModificar = null;
}
let from = $state(page.url.searchParams.get('from'));
$effect(() => {
goto('/', { replaceState: true });
});
</script>
{#if from == 'cambio_contraseña'}
<Dialog
open={true}
onOpenChange={() => {
from = '';
}}
>
<DialogContent>Se cambio la contraseña del usuario exitosamente</DialogContent>
</Dialog>
{/if}
<svelte:head>
<meta property="og:title" content="Mini-x" />
<meta property="og:description" content="Pagina Principal" />

View File

@@ -0,0 +1,35 @@
<script lang="ts">
import BreadcrumbItem from '@/components/ui/breadcrumb/breadcrumb-item.svelte';
import BreadcrumbList from '@/components/ui/breadcrumb/breadcrumb-list.svelte';
import BreadcrumbSeparator from '@/components/ui/breadcrumb/breadcrumb-separator.svelte';
import Breadcrumb from '@/components/ui/breadcrumb/breadcrumb.svelte';
import Pasos from './Pasos.svelte';
import Card from '@/components/ui/card/card.svelte';
import CardContent from '@/components/ui/card/card-content.svelte';
import { slide } from 'svelte/transition';
import IngresarEmail from './IngresarEmail.svelte';
import Otp from './Otp.svelte';
import NuevaPass from './NuevaPass.svelte';
let estado: 'email' | 'otp' | 'nuevapass' = $state('email');
let email: string = $state('');
let otp: string = $state('');
</script>
<div class="flex min-h-fit w-full items-center justify-center p-6 md:p-10">
<div class="w-full max-w-6xl">
<Pasos {estado} />
<div class="mt-6">
{#if estado === 'email'}
<IngresarEmail bind:estado bind:email />
{/if}
{#if estado === 'otp'}
<Otp bind:estado {email} bind:otp />
{/if}
{#if estado === 'nuevapass'}
<NuevaPass {otp} {email} />
{/if}
</div>
</div>
</div>

View File

@@ -0,0 +1,101 @@
<script lang="ts">
import CardError from '@/components/CardError.svelte';
import Button from '@/components/ui/button/button.svelte';
import CardContent from '@/components/ui/card/card-content.svelte';
import Card from '@/components/ui/card/card.svelte';
import DialogContent from '@/components/ui/dialog/dialog-content.svelte';
import Dialog from '@/components/ui/dialog/dialog.svelte';
import Input from '@/components/ui/input/input.svelte';
import Spinner from '@/components/ui/spinner/spinner.svelte';
import { checkEmail } from '@/hooks/checkEmail';
import { apiBase } from '@/stores/url';
import Check from '@lucide/svelte/icons/check';
import Cross from '@lucide/svelte/icons/x';
import { slide } from 'svelte/transition';
let { estado = $bindable(), email = $bindable() } = $props();
let checkeado = $state<Boolean | null>(null);
let esEmailExistente = $state<boolean>(false);
let mensajeError = $state('');
let lastemail: string;
$effect(() => {
if (email == '' || !email.includes('@')) {
checkeado = null;
return;
}
let timeoutId: ReturnType<typeof setTimeout> | undefined;
(async () => {
if (timeoutId) clearTimeout(timeoutId);
timeoutId = setTimeout(async () => {
checkeado = true;
await checkEmaill();
checkeado = false;
}, 1000);
})();
});
async function checkEmaill() {
try {
if (lastemail == email) return;
lastemail = email;
esEmailExistente = !(await checkEmail(email));
} catch {
esEmailExistente = false;
}
}
</script>
{#if mensajeError}
<Dialog open={!!mensajeError} onOpenChange={() => (mensajeError = '')}>
<DialogContent>
<CardError {mensajeError} />
</DialogContent>
</Dialog>
{/if}
<div transition:slide>
<Card>
<CardContent>
<div class="flex flex-col gap-4">
<h2 class="flex items-center justify-between text-xl font-semibold">
Ingresa tu correo electrónico
{#if checkeado == null}
<div hidden></div>
{:else if checkeado == true}
<Spinner></Spinner>
{:else if esEmailExistente}
<Check class="text-green-500" />
{:else}
<Cross class="text-red-500" />
{/if}
</h2>
<form
onsubmit={async (e) => {
e.preventDefault();
try {
const formData = new FormData();
formData.append('email', email);
const req = await fetch(`${$apiBase}/api/password-reset/otp`, {
method: 'POST',
body: formData
});
if (req.ok) {
estado = 'otp';
return;
}
mensajeError = await req.text();
} catch {
mensajeError = 'No se pudo alcanzar el servidor';
}
}}
>
<div class="flex flex-col gap-2">
<Input type="email" placeholder="correo@ejemplo.com" bind:value={email} />
<Button type="submit" disabled={!esEmailExistente}>Enviar código</Button>
</div>
</form>
</div>
</CardContent>
</Card>
</div>

View File

@@ -0,0 +1,96 @@
<script>
import { goto } from '$app/navigation';
import CardError from '@/components/CardError.svelte';
import Button from '@/components/ui/button/button.svelte';
import CardContent from '@/components/ui/card/card-content.svelte';
import Card from '@/components/ui/card/card.svelte';
import DialogContent from '@/components/ui/dialog/dialog-content.svelte';
import Dialog from '@/components/ui/dialog/dialog.svelte';
import Input from '@/components/ui/input/input.svelte';
import Label from '@/components/ui/label/label.svelte';
import { apiBase } from '@/stores/url';
import { sesionStore } from '@/stores/usuario';
import { slide } from 'svelte/transition';
let { otp, email } = $props();
let pass = $state('');
let pass2 = $state('');
let coinsiden = $derived(
pass === pass2 &&
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^A-Za-z0-9])[A-Za-z\d\W_]*$/.test(pass) &&
pass.length > 8
);
let mensajeError = $state('');
</script>
{#if mensajeError}
<Dialog open={!!mensajeError} onOpenChange={() => (mensajeError = '')}>
<DialogContent>
<CardError {mensajeError} />
</DialogContent>
</Dialog>
{/if}
<div transition:slide>
<Card>
<CardContent>
<h2 class="mb-4 text-2xl font-bold">Crear una Nueva Contraseña</h2>
<form
onsubmit={async (e) => {
e.preventDefault();
const formData = new FormData();
formData.append('otp', otp);
formData.append('email', email);
formData.append('newpass', pass);
try {
const req = await fetch(`${$apiBase}/api/password-reset/change`, {
method: 'PATCH',
body: formData
});
if (req.ok) {
const token = await req.json();
sesionStore.set(token);
goto('/?from=cambio_contraseña');
return;
}
const data = await req.text();
mensajeError = data;
} catch {
mensajeError = 'No se pudo alcanzar el servidor';
}
}}
>
<div class="space-y-4">
<div>
<Label for="password" class="mb-1 block text-sm font-medium">Contraseña</Label>
<Input
type="password"
id="password"
class="w-full px-3 py-2"
placeholder="Ingresa tu nueva contraseña"
bind:value={pass}
/>
<p class="mt-2 text-sm text-gray-500">
La contraseña debe contener al menos una mayúscula, una minúscula, un número y un
carácter especial. Además de 8 chars de longitud.
</p>
</div>
<div>
<Label for="confirmPassword" class="mb-1 block text-sm font-medium"
>Repetir Contraseña</Label
>
<Input
type="password"
id="confirmPassword"
class="w-full px-3 py-2"
placeholder="Repite tu nueva contraseña"
bind:value={pass2}
/>
</div>
<Button disabled={!coinsiden} type="submit">Establecer Contraseña</Button>
</div>
</form>
</CardContent>
</Card>
</div>

View File

@@ -0,0 +1,92 @@
<script>
import CardError from '@/components/CardError.svelte';
import Button from '@/components/ui/button/button.svelte';
import CardContent from '@/components/ui/card/card-content.svelte';
import Card from '@/components/ui/card/card.svelte';
import DialogContent from '@/components/ui/dialog/dialog-content.svelte';
import Dialog from '@/components/ui/dialog/dialog.svelte';
import InputOtpGroup from '@/components/ui/input-otp/input-otp-group.svelte';
import InputOtpSlot from '@/components/ui/input-otp/input-otp-slot.svelte';
import InputOtp from '@/components/ui/input-otp/input-otp.svelte';
import { apiBase } from '@/stores/url';
import { slide } from 'svelte/transition';
let { estado = $bindable(), email, otp = $bindable() } = $props();
let checkeado = $state(false);
let mensajeError = $state('');
$effect(() => {
if (otp && otp.length === 6) {
checkeado = true;
} else {
checkeado = false;
}
});
</script>
{#if mensajeError}
<Dialog open={!!mensajeError} onOpenChange={() => (mensajeError = '')}>
<DialogContent>
<CardError {mensajeError} />
</DialogContent>
</Dialog>
{/if}
<div transition:slide>
<Card>
<CardContent>
<div class="space-y-6 py-4">
<h3 class="text-xl font-semibold">Verificación de correo</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">
Hemos enviado un código de verificación a tu correo electrónico.
</p>
<div class="space-y-4">
<div class="flex justify-center">
<InputOtp maxlength={6} bind:value={otp}>
{#snippet children({ cells })}
<InputOtpGroup>
{#each cells as cell}
<InputOtpSlot class="p-3!" {cell}></InputOtpSlot>
{/each}
</InputOtpGroup>
{/snippet}
</InputOtp>
</div>
<div class="flex justify-between">
<Button
disabled={!checkeado}
onclick={async () => {
try {
const formData = new FormData();
formData.append('otp', otp);
formData.append('email', email);
let req = await fetch(`${$apiBase}/api/password-reset/otp/check`, {
method: 'POST',
body: formData
});
if (req.ok) {
estado = 'nuevapass';
return;
} else {
mensajeError = await req.text();
}
} catch {
mensajeError = 'No se pudo alcanzar el servidor';
}
}}
>
Verificar
</Button>
<Button variant="link" onclick={() => console.log('Reenviar código')}>
Reenviar código de verificación
</Button>
</div>
</div>
</div>
</CardContent>
</Card>
</div>

View File

@@ -0,0 +1,30 @@
<script>
import BreadcrumbItem from '@/components/ui/breadcrumb/breadcrumb-item.svelte';
import BreadcrumbList from '@/components/ui/breadcrumb/breadcrumb-list.svelte';
import BreadcrumbSeparator from '@/components/ui/breadcrumb/breadcrumb-separator.svelte';
import Breadcrumb from '@/components/ui/breadcrumb/breadcrumb.svelte';
let { estado } = $props();
</script>
<div class="flex w-full justify-center">
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<p class={`select-none ${estado === 'email' ? 'font-bold text-white' : ''}`}>
Ingrese Email
</p>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<p class={`select-none ${estado === 'otp' ? 'font-bold text-white' : ''}`}>Ingresar OTP</p>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<p class={`select-none ${estado === 'nuevapass' ? 'font-bold text-white' : ''}`}>
Nueva Contraseña
</p>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
</div>