mirror of
https://github.com/SrIzan10/lofi.git
synced 2026-06-06 00:56:53 +00:00
feat: initial auth dialog panel
This commit is contained in:
2
bun.lock
2
bun.lock
@@ -15,7 +15,7 @@
|
||||
"@types/node": "^24",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"better-auth": "~1.4.21",
|
||||
"bits-ui": "^1.3.19",
|
||||
"bits-ui": "^1.4.7",
|
||||
"clsx": "^2.1.1",
|
||||
"drizzle-kit": "^0.31.8",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
{
|
||||
"$schema": "https://next.shadcn-svelte.com/schema.json",
|
||||
"style": "new-york",
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.ts",
|
||||
"css": "src/app.css",
|
||||
"baseColor": "neutral"
|
||||
},
|
||||
@@ -10,8 +8,9 @@
|
||||
"components": "$lib/components",
|
||||
"utils": "$lib/utils",
|
||||
"ui": "$lib/components/ui",
|
||||
"hooks": "$lib/hooks"
|
||||
"hooks": "$lib/hooks",
|
||||
"lib": "$lib"
|
||||
},
|
||||
"typescript": true,
|
||||
"registry": "https://next.shadcn-svelte.com/registry"
|
||||
"registry": "https://tw3.shadcn-svelte.com/registry/new-york"
|
||||
}
|
||||
|
||||
12
package.json
12
package.json
@@ -25,6 +25,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@better-auth/cli": "~1.4.21",
|
||||
"@better-auth/passkey": "~1.4.21",
|
||||
"@cloudflare/workers-types": "^4.20250517.0",
|
||||
"@lucide/svelte": "^0.492.0",
|
||||
"@sveltejs/adapter-cloudflare": "^7.2.6",
|
||||
@@ -32,9 +33,12 @@
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"@types/node": "^24",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"bits-ui": "^1.3.19",
|
||||
"better-auth": "~1.4.21",
|
||||
"bits-ui": "^1.4.7",
|
||||
"clsx": "^2.1.1",
|
||||
"drizzle-kit": "^0.31.8",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"mode-watcher": "^1.0.5",
|
||||
"prettier": "^3.5.3",
|
||||
"prettier-plugin-svelte": "^3.3.3",
|
||||
"svelte": "^5.0.0",
|
||||
@@ -46,11 +50,7 @@
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"typescript": "^5.0.0",
|
||||
"vite": "^6.0.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"
|
||||
"wrangler": "^4.63.0"
|
||||
},
|
||||
"packageManager": "bun@1.3.5"
|
||||
}
|
||||
|
||||
@@ -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 { 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({
|
||||
plugins: [passkeyClient()],
|
||||
plugins: [inferAdditionalFields<typeof auth>(), anonymousClient(), passkeyClient(), accountNumberClient],
|
||||
});
|
||||
|
||||
|
||||
@@ -1,39 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { authClient } from "@/auth-client";
|
||||
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;
|
||||
};
|
||||
import AuthDialog from './auth-dialog.svelte';
|
||||
</script>
|
||||
|
||||
<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"
|
||||
>
|
||||
{#if $session.data}
|
||||
<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}
|
||||
<AuthDialog />
|
||||
</div>
|
||||
|
||||
261
src/lib/components/app/auth-dialog.svelte
Normal file
261
src/lib/components/app/auth-dialog.svelte
Normal 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>
|
||||
7
src/lib/components/ui/input/index.ts
Normal file
7
src/lib/components/ui/input/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import Root from "./input.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Input,
|
||||
};
|
||||
46
src/lib/components/ui/input/input.svelte
Normal file
46
src/lib/components/ui/input/input.svelte
Normal 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}
|
||||
7
src/lib/components/ui/label/index.ts
Normal file
7
src/lib/components/ui/label/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import Root from "./label.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Label,
|
||||
};
|
||||
19
src/lib/components/ui/label/label.svelte
Normal file
19
src/lib/components/ui/label/label.svelte
Normal 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}
|
||||
/>
|
||||
@@ -14,6 +14,8 @@ import * as z from 'zod';
|
||||
const generateAccountNumber = () =>
|
||||
Array.from(crypto.getRandomValues(new Uint8Array(16)), (value) => (value % 10).toString()).join('');
|
||||
|
||||
const generateOpaqueIdentifier = () => `${crypto.randomUUID()}@internal.invalid`;
|
||||
|
||||
const accountNumber = () =>
|
||||
({
|
||||
id: 'account-number',
|
||||
@@ -35,17 +37,7 @@ const accountNumber = () =>
|
||||
value: ctx.body.accountNumber,
|
||||
},
|
||||
],
|
||||
})) as
|
||||
| ({
|
||||
id: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
email: string;
|
||||
emailVerified: boolean;
|
||||
name: string;
|
||||
image?: string | null;
|
||||
} & Record<string, any>)
|
||||
| null;
|
||||
})) as (Record<string, any> | null);
|
||||
|
||||
if (!user) {
|
||||
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({
|
||||
token: session.token,
|
||||
@@ -74,7 +69,6 @@ const accountNumber = () =>
|
||||
const authConfig = {
|
||||
baseURL: env.ORIGIN,
|
||||
secret: env.BETTER_AUTH_SECRET,
|
||||
emailAndPassword: { enabled: false },
|
||||
user: {
|
||||
additionalFields: {
|
||||
accountNumber: {
|
||||
@@ -98,7 +92,7 @@ const authConfig = {
|
||||
plugins: [
|
||||
anonymous({
|
||||
generateName: () => 'Chillhop listener',
|
||||
emailDomainName: 'accounts.chillhop.local',
|
||||
generateRandomEmail: generateOpaqueIdentifier,
|
||||
}),
|
||||
accountNumber(),
|
||||
passkey({
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import type { Actions } from './$types';
|
||||
import type { PageServerLoad } from './$types';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
import { APIError } from 'better-auth';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user