@stnd/account 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@stnd/account",
3
+ "version": "0.1.1",
4
+ "type": "module",
5
+ "svelte": "./ui/UserMenu.svelte",
6
+ "main": "./index.module.js",
7
+ "exports": {
8
+ ".": "./index.module.js",
9
+ "./auth": "./auth.js",
10
+ "./client": "./client.svelte.js",
11
+ "./models": "./models",
12
+ "./ui": "./ui",
13
+ "./views": "./views",
14
+ "./*": "./*"
15
+ },
16
+ "dependencies": {
17
+ "@better-auth/kysely-adapter": "1.6.22",
18
+ "@better-auth/passkey": "^1.6.22",
19
+ "better-auth": "^1.6.22",
20
+ "kysely": "^0.29.2",
21
+ "kysely-d1": "^0.4.0",
22
+ "nanostores": "^1.4.0",
23
+ "wrangler": "^4.105.0",
24
+ "@stnd/art": "0.1.0",
25
+ "@stnd/icon": "0.2.0",
26
+ "@stnd/log": "0.5.0",
27
+ "@stnd/styles": "0.5.2",
28
+ "@stnd/utils": "0.5.0"
29
+ },
30
+ "peerDependencies": {
31
+ "astro": ">=5.0.0"
32
+ },
33
+ "publishConfig": {
34
+ "access": "public"
35
+ }
36
+ }
package/route.js ADDED
@@ -0,0 +1,25 @@
1
+ import { initAuth } from "./auth.js";
2
+
3
+ /**
4
+ * BetterAuth API Route Handler
5
+ *
6
+ * Mounts the BetterAuth catch-all handler to /api/auth/*
7
+ */
8
+ export const ALL = async (context) => {
9
+ const { request, locals } = context;
10
+
11
+ let env = locals?.cfContext?.env ?? locals?.env ?? {};
12
+ if (Object.keys(env).length === 0) {
13
+ try {
14
+ const cf = await import("cloudflare:workers");
15
+ if (cf?.env) env = cf.env;
16
+ } catch { } // Ignore
17
+ }
18
+ try {
19
+ const auth = initAuth(env);
20
+ return await auth.handler(request);
21
+ } catch (e) {
22
+ console.error("[Account Route Error]", e);
23
+ return new Response(JSON.stringify({ error: e.message }), { status: 500 });
24
+ }
25
+ };
package/schema.sql ADDED
@@ -0,0 +1,70 @@
1
+ -- BetterAuth Standard Schema for Cloudflare D1
2
+ -- Save this file in your app's migrations folder or run directly via wrangler d1 execute
3
+
4
+ CREATE TABLE "user" (
5
+ "id" TEXT NOT NULL PRIMARY KEY,
6
+ "name" TEXT NOT NULL,
7
+ "email" TEXT NOT NULL UNIQUE,
8
+ "emailVerified" INTEGER NOT NULL,
9
+ "image" TEXT,
10
+ "createdAt" INTEGER NOT NULL,
11
+ "updatedAt" INTEGER NOT NULL,
12
+ "access_rights" TEXT,
13
+ "settings" TEXT,
14
+ "role" TEXT,
15
+ "banned" INTEGER,
16
+ "banReason" TEXT,
17
+ "banExpires" INTEGER
18
+ );
19
+
20
+ CREATE TABLE "session" (
21
+ "id" TEXT NOT NULL PRIMARY KEY,
22
+ "expiresAt" INTEGER NOT NULL,
23
+ "token" TEXT NOT NULL UNIQUE,
24
+ "createdAt" INTEGER NOT NULL,
25
+ "updatedAt" INTEGER NOT NULL,
26
+ "ipAddress" TEXT,
27
+ "userAgent" TEXT,
28
+ "userId" TEXT NOT NULL,
29
+ FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE
30
+ );
31
+
32
+ CREATE TABLE "account" (
33
+ "id" TEXT NOT NULL PRIMARY KEY,
34
+ "accountId" TEXT NOT NULL,
35
+ "providerId" TEXT NOT NULL,
36
+ "userId" TEXT NOT NULL,
37
+ "accessToken" TEXT,
38
+ "refreshToken" TEXT,
39
+ "idToken" TEXT,
40
+ "accessTokenExpiresAt" INTEGER,
41
+ "refreshTokenExpiresAt" INTEGER,
42
+ "scope" TEXT,
43
+ "password" TEXT,
44
+ "createdAt" INTEGER NOT NULL,
45
+ "updatedAt" INTEGER NOT NULL,
46
+ FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE
47
+ );
48
+
49
+ CREATE TABLE "verification" (
50
+ "id" TEXT NOT NULL PRIMARY KEY,
51
+ "identifier" TEXT NOT NULL,
52
+ "value" TEXT NOT NULL,
53
+ "expiresAt" INTEGER NOT NULL,
54
+ "createdAt" INTEGER,
55
+ "updatedAt" INTEGER
56
+ );
57
+
58
+ CREATE TABLE "passkey" (
59
+ "id" TEXT NOT NULL PRIMARY KEY,
60
+ "name" TEXT,
61
+ "publicKey" TEXT NOT NULL,
62
+ "userId" TEXT NOT NULL,
63
+ "webauthnUserID" TEXT NOT NULL,
64
+ "counter" INTEGER NOT NULL,
65
+ "deviceType" TEXT NOT NULL,
66
+ "backedUp" INTEGER NOT NULL,
67
+ "transports" TEXT,
68
+ "createdAt" INTEGER,
69
+ FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE
70
+ );
@@ -0,0 +1,115 @@
1
+ <script>
2
+ /**
3
+ * Standard Avatar — A premium, reusable avatar component.
4
+ * Handles:
5
+ * 1. Profile Image (src)
6
+ * 2. Generative Art Fallback (via @stnd/art)
7
+ * 3. Initials overlay
8
+ *
9
+ * When used inside an <IdentityProvider>, it auto-reads
10
+ * the user's visual identity. Otherwise, falls back to
11
+ * name/email props as the seed.
12
+ */
13
+ import { getContext } from "svelte";
14
+ import GenerativeArt from "@stnd/art/components/GenerativeArt.svelte";
15
+ import { hashCode } from "@stnd/art/lib/seed.js";
16
+ import { all as allStyles } from "@stnd/art/lib/styles/index.js";
17
+
18
+ let {
19
+ src = null,
20
+ name = "",
21
+ email = "",
22
+ size = "var(--space-12)",
23
+ border = false,
24
+ shadow = false,
25
+ } = $props();
26
+
27
+ // Try to read identity from context (IdentityProvider)
28
+ // Falls back to local seed derivation if not wrapped
29
+ const getIdentity = getContext("identity") ?? null;
30
+
31
+ const localSeed = $derived(name || email || "guest");
32
+ const localHash = $derived(hashCode(localSeed));
33
+ const localStyle = $derived(allStyles[localHash % allStyles.length].name);
34
+
35
+ // Use identity context if available, otherwise derive locally
36
+ const seed = $derived(
37
+ getIdentity ? getIdentity().seed("avatar") : localSeed,
38
+ );
39
+ const style = $derived(
40
+ getIdentity ? getIdentity().signatureStyle : localStyle,
41
+ );
42
+
43
+ const initial = $derived(
44
+ (name || (getIdentity ? getIdentity().username : "") || email || "?")
45
+ .split(/[ @._-]/)
46
+ .filter(Boolean)
47
+ .map((part) => part[0])
48
+ .join("")
49
+ .slice(0, 2)
50
+ .toUpperCase(),
51
+ );
52
+ </script>
53
+
54
+ <div
55
+ class="stnd-avatar"
56
+ style:--avatar-size={size}
57
+ class:has-border={border}
58
+ class:has-shadow={shadow}
59
+ >
60
+ {#if src}
61
+ <img {src} alt={name || "User Avatar"} loading="lazy" />
62
+ {:else}
63
+ <GenerativeArt {seed} variant={style} resolution={200}>
64
+ <span class="initials">{initial}</span>
65
+ </GenerativeArt>
66
+ {/if}
67
+ </div>
68
+
69
+ <style>
70
+ .stnd-avatar {
71
+ width: var(--avatar-size);
72
+ height: var(--avatar-size);
73
+ border-radius: 50%;
74
+ overflow: hidden;
75
+ position: relative;
76
+ display: flex;
77
+ align-items: center;
78
+ justify-content: center;
79
+ background: var(--color-background);
80
+ flex-shrink: 0;
81
+ }
82
+
83
+ .has-border {
84
+ outline: 2px solid var(--identity-primary, oklch(100% 0 0 / 0.3));
85
+ outline-offset: -2px;
86
+ }
87
+
88
+ .has-shadow {
89
+ box-shadow: 0 2px 12px var(--identity-glow, oklch(0% 0 0 / 0.25));
90
+ }
91
+
92
+ img {
93
+ width: 100%;
94
+ height: 100%;
95
+ object-fit: cover;
96
+ filter: saturate(0.9);
97
+ display: block;
98
+ }
99
+
100
+ img:hover {
101
+ filter: saturate(1);
102
+ }
103
+
104
+ .initials {
105
+ font-size: calc(var(--avatar-size) * 0.35);
106
+ font-weight: 700;
107
+ letter-spacing: -0.02em;
108
+ color: var(--color-on-accent, white);
109
+ text-shadow:
110
+ 0 1px 4px rgba(0, 0, 0, 0.4),
111
+ 0 0 12px rgba(0, 0, 0, 0.15);
112
+ font-family: var(--font-interface);
113
+ pointer-events: none;
114
+ }
115
+ </style>
@@ -0,0 +1,225 @@
1
+ <script>
2
+ import Avatar from "./Avatar.svelte";
3
+ import Icon from "@stnd/icon/Icon.svelte";
4
+ import { onMount } from "svelte";
5
+ import { signOut } from "../client.svelte.js";
6
+
7
+ let {
8
+ user,
9
+ children,
10
+ onLogoutCallback = () => window.location.reload(),
11
+ avatarHref,
12
+ avatarLauncherView,
13
+ } = $props();
14
+
15
+ let isOpen = $state(false);
16
+ let container = $state(null);
17
+ let hoverTimer;
18
+
19
+ function handleMouseEnter() {
20
+ clearTimeout(hoverTimer);
21
+ isOpen = true;
22
+ }
23
+
24
+ function handleMouseLeave() {
25
+ hoverTimer = setTimeout(() => {
26
+ isOpen = false;
27
+ }, 300);
28
+ }
29
+
30
+ onMount(() => {
31
+ const handleOutside = (e) => {
32
+ if (isOpen && container && !container.contains(e.target)) {
33
+ isOpen = false;
34
+ }
35
+ };
36
+ window.addEventListener("click", handleOutside);
37
+
38
+ return () => {
39
+ window.removeEventListener("click", handleOutside);
40
+ };
41
+ });
42
+
43
+ function toggleMenu(e) {
44
+ e.stopPropagation();
45
+ isOpen = !isOpen;
46
+ }
47
+
48
+ async function handleLogout() {
49
+ isOpen = false;
50
+ await signOut({
51
+ fetchOptions: {
52
+ onSuccess: onLogoutCallback,
53
+ },
54
+ });
55
+ }
56
+ </script>
57
+
58
+ {#if user}
59
+ <div
60
+ class="user-avatar-wrapper no-rhytm"
61
+ bind:this={container}
62
+ onmouseenter={handleMouseEnter}
63
+ onmouseleave={handleMouseLeave}
64
+ role="none"
65
+ >
66
+ {#if avatarLauncherView}
67
+ <button
68
+ data-launcher-view={avatarLauncherView}
69
+ class="user-avatar-trigger no-shadow no-decoration"
70
+ title={user.name || user.username || user.email}
71
+ aria-expanded={isOpen}
72
+ aria-haspopup="true"
73
+ style="background: transparent; border: none; padding: 0;"
74
+ >
75
+ <Avatar
76
+ src={user.image}
77
+ name={user.username || user.name}
78
+ email={user.email}
79
+ size="100%"
80
+ border={false}
81
+ shadow={false}
82
+ />
83
+ </button>
84
+ {:else}
85
+ <a
86
+ href={avatarHref || `/@${user.username}`}
87
+ class="user-avatar-trigger no-shadow no-decoration"
88
+ title={user.name || user.username || user.email}
89
+ aria-expanded={isOpen}
90
+ aria-haspopup="true"
91
+ >
92
+ <Avatar
93
+ src={user.image}
94
+ name={user.username || user.name}
95
+ email={user.email}
96
+ size="100%"
97
+ border={false}
98
+ shadow={false}
99
+ />
100
+ </a>
101
+ {/if}
102
+
103
+ {#if isOpen}
104
+ <div class="user-dropdown">
105
+ <div class="dropdown-header">
106
+ <h6 class="name">{user.name || user.username || "User"}</h6>
107
+ <span class="text-2xs mt-trim text-subtle">{user.email}</span>
108
+ </div>
109
+
110
+ <div class="dropdown-divider"></div>
111
+
112
+ {@render children?.()}
113
+
114
+ <button
115
+ class="ghost"
116
+ data-launcher-view="::account-settings"
117
+ onclick={() => (isOpen = false)}
118
+ >
119
+ <Icon icon="settings" />
120
+ <span>Settings</span>
121
+ </button>
122
+
123
+ <button class="ghost logout" onclick={handleLogout}>
124
+ <Icon icon="logout" />
125
+ <span>Log Out</span>
126
+ </button>
127
+ </div>
128
+ {/if}
129
+ </div>
130
+ {/if}
131
+
132
+ <style>
133
+ .user-avatar-wrapper {
134
+ position: relative;
135
+ display: flex;
136
+ }
137
+
138
+ .user-avatar-trigger {
139
+ width: var(--space);
140
+ height: var(--space);
141
+ display: flex;
142
+ align-items: center;
143
+ justify-content: center;
144
+ cursor: pointer;
145
+ transition: all var(--transition);
146
+
147
+ box-shadow: var(--shadow-lg), var(--shadow), var(--shadow-ring);
148
+ border-radius: 50%;
149
+ overflow: hidden;
150
+ flex-shrink: 0;
151
+ padding: 0 !important;
152
+ margin: 0;
153
+ }
154
+
155
+ .user-avatar-trigger:hover {
156
+ transform: translateY(-1px);
157
+ box-shadow: var(--shadow-glow);
158
+ border-color: var(--color-accent);
159
+ }
160
+
161
+ .user-dropdown {
162
+ position: absolute;
163
+ top: calc(100% + var(--space-d2));
164
+ font-family: var(--font-interface);
165
+ font-size: var(--size-sm);
166
+ right: 0;
167
+ width: 200px;
168
+ background: var(--color-surface-high);
169
+ border-radius: var(--radius);
170
+ box-shadow: var(--shadow-lift), var(--shadow-raised);
171
+ z-index: 1000;
172
+ padding: var(--space-d2);
173
+ display: flex;
174
+ flex-direction: column;
175
+ gap: var(--space-d8);
176
+ animation: all var(--transition);
177
+ }
178
+
179
+ @keyframes slideDown {
180
+ from {
181
+ opacity: 0;
182
+ transform: translateY(-10px);
183
+ }
184
+ to {
185
+ opacity: 1;
186
+ transform: translateY(0);
187
+ }
188
+ }
189
+
190
+ .dropdown-header {
191
+ display: flex;
192
+ flex-direction: column;
193
+ text-align: center;
194
+ }
195
+
196
+ :global(.dropdown-header) {
197
+ font-size: var(--size-2xs);
198
+ margin-top: var(--space-d3);
199
+ }
200
+ :global(.dropdown-header h6) {
201
+ font-weight: var(--font-weight-bold);
202
+ font-size: var(--size-sm);
203
+ color: var(--color-foreground);
204
+ margin-top: 0;
205
+ margin-bottom: var(--space-d8);
206
+ text-align: center;
207
+ }
208
+
209
+ /*:global(.section-title) {
210
+ font-size: var(--size-xs);
211
+ letter-spacing: 0.05em;
212
+ font-weight: 800;
213
+ color: var(--color-muted);
214
+ margin-inline: auto;
215
+ text-align: center;
216
+ margin-bottom: var(--space-d2);
217
+ }*/
218
+
219
+ .dropdown-divider,
220
+ :global(.dropdown-divider) {
221
+ height: 1px;
222
+ background: var(--color-border);
223
+ margin: var(--space-d2) calc(var(--space-d2) * -1);
224
+ }
225
+ </style>
@@ -0,0 +1,120 @@
1
+ import Log from "@stnd/log";
2
+
3
+ const log = Log({ scope: "AccountPreferences" });
4
+
5
+ /**
6
+ * Create a simple, per-app preference helper.
7
+ *
8
+ * - Local-first (localStorage) for instant UX.
9
+ * - Syncs to a given API endpoint which is responsible for writing to KV and/or D1.
10
+ *
11
+ * This helper is intentionally app-agnostic: each app passes its own endpoint.
12
+ */
13
+ export function createPreferenceHelper({ storageNamespace, endpoint }) {
14
+ if (!storageNamespace) {
15
+ throw new Error("storageNamespace is required for createPreferenceHelper");
16
+ }
17
+ if (!endpoint) {
18
+ throw new Error("endpoint is required for createPreferenceHelper");
19
+ }
20
+
21
+ const STORAGE_KEY = `stnd:prefs:${storageNamespace}`;
22
+
23
+ function readLocal() {
24
+ if (typeof window === "undefined") return null;
25
+ try {
26
+ const raw = window.localStorage.getItem(STORAGE_KEY);
27
+ if (!raw) return null;
28
+ return JSON.parse(raw);
29
+ } catch (e) {
30
+ log.warn("Failed to parse local preferences", e);
31
+ return null;
32
+ }
33
+ }
34
+
35
+ function writeLocal(prefs) {
36
+ if (typeof window === "undefined") return;
37
+ try {
38
+ window.localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs || {}));
39
+ } catch (e) {
40
+ log.warn("Failed to write local preferences", e);
41
+ }
42
+ }
43
+
44
+ async function fetchRemote() {
45
+ try {
46
+ const res = await fetch(endpoint, {
47
+ credentials: "include",
48
+ });
49
+ if (!res.ok) return null;
50
+ const json = await res.json();
51
+ return (json && json.preferences) || null;
52
+ } catch (e) {
53
+ log.warn("Failed to fetch remote preferences", e);
54
+ return null;
55
+ }
56
+ }
57
+
58
+ async function pushRemote(patch) {
59
+ try {
60
+ await fetch(endpoint, {
61
+ method: "POST",
62
+ credentials: "include",
63
+ headers: { "Content-Type": "application/json" },
64
+ body: JSON.stringify({ patch }),
65
+ });
66
+ } catch (e) {
67
+ log.warn("Failed to push remote preferences", e);
68
+ }
69
+ }
70
+
71
+ return {
72
+ /**
73
+ * Local-first read. Returns immediately with local value if present,
74
+ * and in the background hydrates from remote.
75
+ */
76
+ async get(key, fallback = null) {
77
+ const local = readLocal();
78
+ if (local && Object.prototype.hasOwnProperty.call(local, key)) {
79
+ return local[key];
80
+ }
81
+
82
+ // Background hydrate
83
+ fetchRemote().then((remote) => {
84
+ if (!remote) return;
85
+ writeLocal(remote);
86
+ });
87
+
88
+ return fallback;
89
+ },
90
+
91
+ /**
92
+ * Optimistic write:
93
+ * - Updates localStorage synchronously
94
+ * - Sends a patch to the API (KV/D1 sync is app responsibility)
95
+ */
96
+ set(key, value) {
97
+ const current = readLocal() || {};
98
+ const next = { ...current, [key]: value };
99
+ writeLocal(next);
100
+ void pushRemote({ [key]: value });
101
+ },
102
+
103
+ /**
104
+ * Load all preferences for this namespace into localStorage.
105
+ */
106
+ async hydrate() {
107
+ const local = readLocal();
108
+ if (local) return local;
109
+
110
+ const remote = await fetchRemote();
111
+ if (remote) {
112
+ writeLocal(remote);
113
+ return remote;
114
+ }
115
+
116
+ return {};
117
+ },
118
+ };
119
+ }
120
+