feat: initial auth dialog panel

This commit is contained in:
2026-04-04 00:03:41 +02:00
parent 455815c0fb
commit 12b0d2da31
12 changed files with 378 additions and 61 deletions

View File

@@ -15,7 +15,7 @@
"@types/node": "^24", "@types/node": "^24",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"better-auth": "~1.4.21", "better-auth": "~1.4.21",
"bits-ui": "^1.3.19", "bits-ui": "^1.4.7",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"drizzle-kit": "^0.31.8", "drizzle-kit": "^0.31.8",
"drizzle-orm": "^0.45.2", "drizzle-orm": "^0.45.2",

View File

@@ -1,8 +1,6 @@
{ {
"$schema": "https://next.shadcn-svelte.com/schema.json", "$schema": "https://next.shadcn-svelte.com/schema.json",
"style": "new-york",
"tailwind": { "tailwind": {
"config": "tailwind.config.ts",
"css": "src/app.css", "css": "src/app.css",
"baseColor": "neutral" "baseColor": "neutral"
}, },
@@ -10,8 +8,9 @@
"components": "$lib/components", "components": "$lib/components",
"utils": "$lib/utils", "utils": "$lib/utils",
"ui": "$lib/components/ui", "ui": "$lib/components/ui",
"hooks": "$lib/hooks" "hooks": "$lib/hooks",
"lib": "$lib"
}, },
"typescript": true, "typescript": true,
"registry": "https://next.shadcn-svelte.com/registry" "registry": "https://tw3.shadcn-svelte.com/registry/new-york"
} }

View File

@@ -25,6 +25,7 @@
}, },
"devDependencies": { "devDependencies": {
"@better-auth/cli": "~1.4.21", "@better-auth/cli": "~1.4.21",
"@better-auth/passkey": "~1.4.21",
"@cloudflare/workers-types": "^4.20250517.0", "@cloudflare/workers-types": "^4.20250517.0",
"@lucide/svelte": "^0.492.0", "@lucide/svelte": "^0.492.0",
"@sveltejs/adapter-cloudflare": "^7.2.6", "@sveltejs/adapter-cloudflare": "^7.2.6",
@@ -32,9 +33,12 @@
"@sveltejs/vite-plugin-svelte": "^5.0.0", "@sveltejs/vite-plugin-svelte": "^5.0.0",
"@types/node": "^24", "@types/node": "^24",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"bits-ui": "^1.3.19", "better-auth": "~1.4.21",
"bits-ui": "^1.4.7",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"drizzle-kit": "^0.31.8", "drizzle-kit": "^0.31.8",
"drizzle-orm": "^0.45.2",
"mode-watcher": "^1.0.5",
"prettier": "^3.5.3", "prettier": "^3.5.3",
"prettier-plugin-svelte": "^3.3.3", "prettier-plugin-svelte": "^3.3.3",
"svelte": "^5.0.0", "svelte": "^5.0.0",
@@ -46,11 +50,7 @@
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"typescript": "^5.0.0", "typescript": "^5.0.0",
"vite": "^6.0.0", "vite": "^6.0.0",
"wrangler": "^4.63.0", "wrangler": "^4.63.0"
"better-auth": "~1.4.21",
"drizzle-orm": "^0.45.2",
"mode-watcher": "^1.0.5",
"@better-auth/passkey": "~1.4.21"
}, },
"packageManager": "bun@1.3.5" "packageManager": "bun@1.3.5"
} }

View File

@@ -1,7 +1,22 @@
import type { BetterAuthClientPlugin } from 'better-auth/client';
import { anonymousClient, inferAdditionalFields } from 'better-auth/client/plugins';
import { createAuthClient } from 'better-auth/svelte'; import { createAuthClient } from 'better-auth/svelte';
import { passkeyClient } from '@better-auth/passkey/client'; import { passkeyClient } from '@better-auth/passkey/client';
import type { auth } from '$lib/server/auth';
const accountNumberClient = {
id: 'account-number',
getActions: ($fetch) => ({
signInAccountNumber: async (accountNumber: string) =>
$fetch('/sign-in/account-number', {
method: 'POST',
body: {
accountNumber,
},
}),
}),
} satisfies BetterAuthClientPlugin;
export const authClient = createAuthClient({ export const authClient = createAuthClient({
plugins: [passkeyClient()], plugins: [inferAdditionalFields<typeof auth>(), anonymousClient(), passkeyClient(), accountNumberClient],
}); });

View File

@@ -1,39 +1,9 @@
<script lang="ts"> <script lang="ts">
import { authClient } from "@/auth-client"; import AuthDialog from './auth-dialog.svelte';
import Button from "../ui/button/button.svelte";
const session = authClient.useSession();
const getAccountNumber = () => {
const user = $session.data?.user as
| {
accountNumber?: string;
account_number?: string;
}
| undefined;
return user?.accountNumber ?? user?.account_number ?? null;
};
</script> </script>
<div <div
class="flex absolute top-0 right-0 items-center p-4 bg-white/10 backdrop-blur-lg rounded-bl-xl shadow-lg *:text-bold space-x-2" class="flex absolute top-0 right-0 items-center p-4 bg-white/10 backdrop-blur-lg rounded-bl-xl shadow-lg *:text-bold space-x-2"
> >
{#if $session.data} <AuthDialog />
<div class="text-right">
<p>Signed in as {$session.data.user.name}</p>
{#if getAccountNumber()}
<p class="text-xs opacity-80">#{getAccountNumber()}</p>
{/if}
</div>
<button
class="px-3 py-1 rounded-md bg-red-500 text-white hover:bg-red-600 transition"
on:click={() => authClient.signOut()}
>
Sign out
</button>
{:else}
<Button href="/demo/better-auth/login">
Sign in
</Button>
{/if}
</div> </div>

View File

@@ -0,0 +1,261 @@
<script lang="ts">
import * as Dialog from '$lib/components/ui/dialog/index.js';
import { authClient } from '$lib';
import LogIn from '@lucide/svelte/icons/log-in';
import Settings from '@lucide/svelte/icons/settings-2';
import UserRound from '@lucide/svelte/icons/user-round';
import { Button } from '../ui/button';
import Label from '../ui/label/label.svelte';
import Input from '../ui/input/input.svelte';
import Key from '@lucide/svelte/icons/key';
import Trash2 from '@lucide/svelte/icons/trash-2';
import type { Passkey } from '@better-auth/passkey';
const session = authClient.useSession();
let open = $state(false);
let accountNumber = $state('');
let authMessage = $state('');
let busyAction = $state<string | null>(null);
let passkeyMessage = $state('');
const user = $derived($session.data?.user);
let passkeys = $state<Passkey[]>([]);
let loadedPasskeysForUserId = $state<string | null>(null);
let passkeyName = $state('');
const loadPasskeys = async () => {
if (!user) {
passkeys = [];
loadedPasskeysForUserId = null;
return;
}
const result = await authClient.passkey.listUserPasskeys();
passkeys = result.data ?? [];
loadedPasskeysForUserId = user.id;
};
$effect(() => {
if (!user) {
passkeys = [];
loadedPasskeysForUserId = null;
return;
}
if (loadedPasskeysForUserId !== user.id) {
loadPasskeys();
}
});
const runAuthAction = async (
action: string,
request: () => Promise<{ error?: { message?: string | null } | null }>,
fallbackMessage: string,
onSuccess?: () => void,
) => {
busyAction = action;
authMessage = '';
const result = await request();
busyAction = null;
if (result.error) {
authMessage = result.error.message || fallbackMessage;
return;
}
onSuccess?.();
open = false;
};
const signInWithAccountNumber = () =>
runAuthAction(
'account-number',
() => authClient.signInAccountNumber(accountNumber.replace(/\D/g, '')),
'Account number sign-in failed',
() => {
accountNumber = '';
},
);
const createAccount = () =>
runAuthAction('create-account', () => authClient.signIn.anonymous(), 'Account creation failed');
const signInWithPasskey = () =>
runAuthAction(
'passkey-sign-in',
() =>
authClient.signIn.passkey({
autoFill: true,
}),
'Passkey sign-in failed',
);
const signOut = () => runAuthAction('sign-out', () => authClient.signOut(), 'Sign out failed');
const addPasskey = async () => {
if (!passkeyName) {
passkeyMessage = 'Please enter a name for your passkey';
return;
}
busyAction = 'add-passkey';
passkeyMessage = '';
const result = await authClient.passkey.addPasskey({
name: passkeyName,
authenticatorAttachment: 'platform',
});
busyAction = null;
if (result.error) {
passkeyMessage = result.error.message || 'Failed to add passkey';
return;
}
await loadPasskeys();
passkeyMessage = 'Passkey added to your account.';
passkeyName = '';
};
const deletePasskey = async (id: string) => {
busyAction = `delete-passkey-${id}`;
passkeyMessage = '';
const result = await authClient.passkey.deletePasskey({ id });
busyAction = null;
if (result.error) {
passkeyMessage = result.error.message || 'Failed to delete passkey';
return;
}
await loadPasskeys();
passkeyMessage = 'Passkey removed from your account.';
};
</script>
<Dialog.Root bind:open>
<Dialog.Trigger>
<Button class="flex items-center gap-2">
{#if user}
<Settings />
Settings
{:else}
<LogIn />
Sign in
{/if}
</Button>
</Dialog.Trigger>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>
{user ? 'Your account' : 'Log into lofi.srizan.dev'}
</Dialog.Title>
</Dialog.Header>
{#if user}
<div class="flex flex-col gap-4">
<div class="rounded-lg border border-white/10 bg-white/5 p-4">
<div class="flex items-start gap-3">
<div class="rounded-full bg-white/10 p-2">
<UserRound class="size-4" />
</div>
<div class="min-w-0">
<p class="font-medium">{user.name}</p>
<p class="text-sm opacity-70">Account #{user.accountNumber}</p>
</div>
</div>
</div>
{#if passkeys.length > 0}
<div class="flex flex-col gap-2">
<p class="text-sm font-medium">Your passkeys</p>
<div class="flex flex-col gap-1">
{#each passkeys as passkey (passkey.id)}
<div class="flex items-center gap-2 rounded-lg border border-white/10 bg-white/5 p-3">
<Key class="size-4" />
<div class="min-w-0 flex-1">
<p>{passkey.name}</p>
<p class="text-sm opacity-70">Added on {new Date(passkey.createdAt).toLocaleDateString()}</p>
</div>
<Button
type="button"
size="icon"
variant="ghost"
aria-label={`Delete passkey ${passkey.name}`}
onclick={() => deletePasskey(passkey.id)}
disabled={busyAction === `delete-passkey-${passkey.id}`}
>
{#if busyAction === `delete-passkey-${passkey.id}`}
...
{:else}
<Trash2 class="size-4" />
{/if}
</Button>
</div>
{/each}
</div>
</div>
{/if}
<div class="flex flex-col gap-2">
<p class="text-sm font-medium">Security</p>
<Input
type="text"
id="passkeyName"
bind:value={passkeyName}
placeholder="Passkey name (e.g. 'My phone')"
/>
<Button onclick={addPasskey} disabled={busyAction === 'add-passkey'}>
{busyAction === 'add-passkey' ? 'Waiting for passkey...' : 'Add a passkey'}
</Button>
{#if passkeyMessage}
<p class="text-sm opacity-80">{passkeyMessage}</p>
{/if}
</div>
<Button variant="ghost" onclick={signOut}>Sign out</Button>
</div>
{:else}
<div class="flex flex-col gap-4">
<div class="flex w-full flex-col gap-1.5">
<Label for="accountNumber">Account Number</Label>
<div class="flex items-center gap-2">
<Input
type="text"
id="accountNumber"
bind:value={accountNumber}
placeholder="7276769420"
autocomplete="one-time-code webauthn"
class="flex-1"
/>
<Button
type="button"
size="icon"
aria-label="Sign in with account number"
onclick={signInWithAccountNumber}
disabled={busyAction === 'account-number'}
>
<Key />
</Button>
</div>
</div>
<Button type="button" onclick={signInWithAccountNumber} disabled={busyAction === 'account-number'}>
{busyAction === 'account-number' ? 'Signing in...' : 'Sign in with account number'}
</Button>
<Button type="button" onclick={signInWithPasskey} disabled={busyAction === 'passkey-sign-in'} variant="ghost">
{busyAction === 'passkey-sign-in' ? 'Waiting for passkey...' : 'Sign in with passkey'}
</Button>
<Button type="button" onclick={createAccount} disabled={busyAction === 'create-account'} variant="ghost">
{busyAction === 'create-account' ? 'Creating account...' : 'Create account number'}
</Button>
{#if authMessage}
<p class="text-sm text-red-400">{authMessage}</p>
{/if}
</div>
{/if}
</Dialog.Content>
</Dialog.Root>

View File

@@ -0,0 +1,7 @@
import Root from "./input.svelte";
export {
Root,
//
Root as Input,
};

View File

@@ -0,0 +1,46 @@
<script lang="ts">
import type { HTMLInputAttributes, HTMLInputTypeAttribute } from "svelte/elements";
import type { WithElementRef } from "bits-ui";
import { cn } from "$lib/utils.js";
type InputType = Exclude<HTMLInputTypeAttribute, "file">;
type Props = WithElementRef<
Omit<HTMLInputAttributes, "type"> &
({ type: "file"; files?: FileList } | { type?: InputType; files?: undefined })
>;
let {
ref = $bindable(null),
value = $bindable(),
type,
files = $bindable(),
class: className,
...restProps
}: Props = $props();
</script>
{#if type === "file"}
<input
bind:this={ref}
class={cn(
"border-input placeholder:text-muted-foreground focus-visible:ring-ring flex h-9 w-full rounded-md border bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:outline-none focus-visible:ring-1 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
type="file"
bind:files
bind:value
{...restProps}
/>
{:else}
<input
bind:this={ref}
class={cn(
"border-input placeholder:text-muted-foreground focus-visible:ring-ring flex h-9 w-full rounded-md border bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:outline-none focus-visible:ring-1 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
{type}
bind:value
{...restProps}
/>
{/if}

View File

@@ -0,0 +1,7 @@
import Root from "./label.svelte";
export {
Root,
//
Root as Label,
};

View File

@@ -0,0 +1,19 @@
<script lang="ts">
import { Label as LabelPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: LabelPrimitive.RootProps = $props();
</script>
<LabelPrimitive.Root
bind:ref
class={cn(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
className
)}
{...restProps}
/>

View File

@@ -14,6 +14,8 @@ import * as z from 'zod';
const generateAccountNumber = () => const generateAccountNumber = () =>
Array.from(crypto.getRandomValues(new Uint8Array(16)), (value) => (value % 10).toString()).join(''); Array.from(crypto.getRandomValues(new Uint8Array(16)), (value) => (value % 10).toString()).join('');
const generateOpaqueIdentifier = () => `${crypto.randomUUID()}@internal.invalid`;
const accountNumber = () => const accountNumber = () =>
({ ({
id: 'account-number', id: 'account-number',
@@ -35,17 +37,7 @@ const accountNumber = () =>
value: ctx.body.accountNumber, value: ctx.body.accountNumber,
}, },
], ],
})) as })) as (Record<string, any> | null);
| ({
id: string;
createdAt: Date;
updatedAt: Date;
email: string;
emailVerified: boolean;
name: string;
image?: string | null;
} & Record<string, any>)
| null;
if (!user) { if (!user) {
throw new APIError('UNAUTHORIZED', { throw new APIError('UNAUTHORIZED', {
@@ -60,7 +52,10 @@ const accountNumber = () =>
}); });
} }
await setSessionCookie(ctx, { session, user }); await setSessionCookie(
ctx,
{ session, user } as Parameters<typeof setSessionCookie>[1],
);
return ctx.json({ return ctx.json({
token: session.token, token: session.token,
@@ -74,7 +69,6 @@ const accountNumber = () =>
const authConfig = { const authConfig = {
baseURL: env.ORIGIN, baseURL: env.ORIGIN,
secret: env.BETTER_AUTH_SECRET, secret: env.BETTER_AUTH_SECRET,
emailAndPassword: { enabled: false },
user: { user: {
additionalFields: { additionalFields: {
accountNumber: { accountNumber: {
@@ -98,7 +92,7 @@ const authConfig = {
plugins: [ plugins: [
anonymous({ anonymous({
generateName: () => 'Chillhop listener', generateName: () => 'Chillhop listener',
emailDomainName: 'accounts.chillhop.local', generateRandomEmail: generateOpaqueIdentifier,
}), }),
accountNumber(), accountNumber(),
passkey({ passkey({

View File

@@ -1,6 +1,5 @@
import { fail, redirect } from '@sveltejs/kit'; import { fail, redirect } from '@sveltejs/kit';
import type { Actions } from './$types'; import type { Actions, PageServerLoad } from './$types';
import type { PageServerLoad } from './$types';
import { APIError } from 'better-auth'; import { APIError } from 'better-auth';