mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-07-16 04:03:47 +03:00
feat: login code input boxes (#1545)
Co-authored-by: james <james@goldfish.net> Co-authored-by: Elias Schneider <login@eliasschneider.com>
This commit is contained in:
@@ -531,6 +531,7 @@
|
||||
"user_has_no_passkeys_yet": "This user has no passkeys yet.",
|
||||
"replay_protection": "Replay Protection",
|
||||
"replay_protection_description": "If enabled the provided token can only be used once. If your provider uses the same token multiple times, you may need to disable this option.",
|
||||
"i_have_a_longer_code": "I have a longer code",
|
||||
"pkce_supported_client_title": "This client supports PKCE",
|
||||
"pkce_supported_client_description": "This client supports Proof Key for Code Exchange (PKCE). PKCE is a security feature that helps protect against certain attacks during the OAuth 2.0 authorization process. It's recommended to enable it."
|
||||
}
|
||||
|
||||
15
frontend/src/lib/components/ui/input-otp/index.ts
Normal file
15
frontend/src/lib/components/ui/input-otp/index.ts
Normal 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,
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
import { cn, type WithElementRef } from '$lib/utils/style.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(
|
||||
'gap-1.5 has-aria-invalid:ring-destructive/20 dark:has-aria-invalid:ring-destructive/40 has-aria-invalid:border-destructive rounded-3xl has-aria-invalid:ring-3 flex items-center',
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import type { WithElementRef } from "$lib/utils/style.js";
|
||||
import { cn } from "$lib/utils/style.js";
|
||||
import MinusIcon from '@lucide/svelte/icons/minus';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="input-otp-separator"
|
||||
role="separator"
|
||||
class={cn("[&_svg:not([class*='size-'])]:size-4 flex items-center", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{#if children}
|
||||
{@render children?.()}
|
||||
{:else}
|
||||
<MinusIcon />
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,33 @@
|
||||
<script lang="ts">
|
||||
import { PinInput as InputOTPPrimitive } from 'bits-ui';
|
||||
import { cn } from '$lib/utils/style.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(
|
||||
'w-10 h-13 rounded-sm bg-input/50 border-input data-active:border-ring data-active:ring-ring/30 data-active:aria-invalid:ring-destructive/20 dark:data-active:aria-invalid:ring-destructive/40 aria-invalid:border-destructive border text-sm transition-all outline-none data-active:ring-3 relative flex items-center justify-center data-active:z-10',
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{cell.char}
|
||||
{#if cell.hasFakeCaret}
|
||||
<div
|
||||
class="cn-input-otp-caret pointer-events-none absolute inset-0 flex items-center justify-center"
|
||||
>
|
||||
<div
|
||||
class="animate-caret-blink bg-foreground h-4 w-px duration-1000 bg-foreground h-4 w-px"
|
||||
></div>
|
||||
</div>
|
||||
{/if}
|
||||
</InputOTPPrimitive.Cell>
|
||||
27
frontend/src/lib/components/ui/input-otp/input-otp.svelte
Normal file
27
frontend/src/lib/components/ui/input-otp/input-otp.svelte
Normal file
@@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
import { PinInput as InputOTPPrimitive } from 'bits-ui';
|
||||
import { cn } from '$lib/utils/style.js';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
value = $bindable(''),
|
||||
...restProps
|
||||
}: InputOTPPrimitive.RootProps = $props();
|
||||
</script>
|
||||
|
||||
<InputOTPPrimitive.Root
|
||||
bind:ref
|
||||
bind:value
|
||||
data-slot="input-otp"
|
||||
spellcheck={false}
|
||||
data-bwignore="true"
|
||||
data-lpignore="true"
|
||||
data-1p-ignore="true"
|
||||
class={cn(
|
||||
'cn-input-otp-input gap-2 flex items-center disabled:cursor-not-allowed has-disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
inputmode="text"
|
||||
/>
|
||||
@@ -2,6 +2,7 @@
|
||||
import { afterNavigate, goto } from '$app/navigation';
|
||||
import SignInWrapper from '$lib/components/login-wrapper.svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as InputOTP from '$lib/components/ui/input-otp/index.js';
|
||||
import Input from '$lib/components/ui/input/input.svelte';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import UserService from '$lib/services/user-service';
|
||||
@@ -17,6 +18,10 @@
|
||||
let error: string | undefined = $state();
|
||||
let backHref = $state('/login/alternative');
|
||||
|
||||
let longCodeRequested = $state(code.length > 6);
|
||||
let showLongCodeOption = $state(true);
|
||||
let codeComplete = $derived(longCodeRequested ? code.length === 16 : code.length === 6);
|
||||
|
||||
const userService = new UserService();
|
||||
|
||||
// If the previous page is a Pocket ID page, go back there instead of the generic alternative login page
|
||||
@@ -28,6 +33,7 @@
|
||||
|
||||
async function authenticate() {
|
||||
if (!code?.trim()) return;
|
||||
if (!codeComplete) return;
|
||||
isLoading = true;
|
||||
try {
|
||||
const user = await userService.exchangeOneTimeAccessToken(code);
|
||||
@@ -49,6 +55,10 @@
|
||||
if (code) {
|
||||
authenticate();
|
||||
}
|
||||
|
||||
if (data.redirect.startsWith('/interaction')) {
|
||||
showLongCodeOption = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -68,18 +78,49 @@
|
||||
{:else}
|
||||
<p class="text-muted-foreground mt-2">{m.enter_the_code_you_received_to_sign_in()}</p>
|
||||
{/if}
|
||||
<form onsubmit={preventDefault(authenticate)} class="w-full max-w-[450px]">
|
||||
<Input
|
||||
id="Code"
|
||||
class="mt-7"
|
||||
placeholder={m.code()}
|
||||
aria-label={m.code()}
|
||||
bind:value={code}
|
||||
type="text"
|
||||
/>
|
||||
<div class="mt-8 flex justify-between gap-2">
|
||||
<Button variant="secondary" class="flex-1" href={backHref}>{m.go_back()}</Button>
|
||||
<Button class="flex-1" type="submit" {isLoading}>{m.submit()}</Button>
|
||||
<form onsubmit={preventDefault(authenticate)} class="flex w-full flex-col items-center mt-8">
|
||||
<div class="flex flex-col w-full justify-center items-center">
|
||||
{#if longCodeRequested}
|
||||
<Input
|
||||
id="Code"
|
||||
class="w-[80%]"
|
||||
placeholder={m.code()}
|
||||
aria-label={m.code()}
|
||||
bind:value={code}
|
||||
type="text"
|
||||
/>
|
||||
{:else}
|
||||
<InputOTP.Root maxlength={6} bind:value={code}>
|
||||
{#snippet children({ cells })}
|
||||
<InputOTP.Group>
|
||||
{#each cells as cell}
|
||||
<InputOTP.Slot {cell} />
|
||||
{/each}
|
||||
</InputOTP.Group>
|
||||
{/snippet}
|
||||
</InputOTP.Root>
|
||||
{/if}
|
||||
{#if !longCodeRequested && showLongCodeOption}
|
||||
<div class="flex justify-center">
|
||||
<Button
|
||||
class="mt-2 text-muted-foreground text-xs"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
type="button"
|
||||
onclick={() => (longCodeRequested = true)}
|
||||
>
|
||||
{m.i_have_a_longer_code()}
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="w-full max-w-[450px] flex gap-4 pt-7">
|
||||
<Button class="flex-1" variant="secondary" href={backHref}>
|
||||
{m.go_back()}
|
||||
</Button>
|
||||
<Button class="flex-1" type="submit" disabled={!codeComplete} {isLoading}>
|
||||
{m.submit()}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</SignInWrapper>
|
||||
|
||||
@@ -17,7 +17,9 @@ test('Sign in with login code', async ({ page }) => {
|
||||
test('Sign in with login code entered manually', async ({ page }) => {
|
||||
const token = oneTimeAccessTokens.filter((t) => !t.expired)[0];
|
||||
await page.goto('/lc');
|
||||
|
||||
|
||||
await page.getByText('I have a longer code').click();
|
||||
|
||||
await page.getByPlaceholder('Code').first().fill(token.token);
|
||||
|
||||
await page.getByText('Submit').first().click();
|
||||
@@ -38,6 +40,8 @@ test('Sign in with login code entered manually fails', async ({ page }) => {
|
||||
const token = oneTimeAccessTokens.filter((t) => t.expired)[0];
|
||||
await page.goto('/lc');
|
||||
|
||||
await page.getByText('I have a longer code').click();
|
||||
|
||||
await page.getByPlaceholder('Code').first().fill(token.token);
|
||||
|
||||
await page.getByText('Submit').first().click();
|
||||
|
||||
Reference in New Issue
Block a user