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:
James18232
2026-07-02 17:56:18 +10:00
committed by GitHub
parent ecad31cae2
commit 8564d35682
8 changed files with 184 additions and 13 deletions

View File

@@ -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."
}

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,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>

View File

@@ -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>

View File

@@ -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>

View 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"
/>

View File

@@ -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>

View File

@@ -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();