@vonall/auth 0.3.0 → 0.4.0

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/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @vonall/auth
2
2
 
3
+ ## 0.4.0
4
+
5
+ ### Minor Changes
6
+
7
+ - The pass-through services are gone: `UserRepo.kontoLoeschen` replaces `KontoService.loeschen`; `EinladungRepo.listOpenBy`/`listAllOpen` replace `EinladungService.offene`/`alleOffenen`, and `EinladungService.zurueckziehen(null, id)` the admin's withdrawal; `AgentRepo` replaces `AgentService`. `@vonall/mcp`'s components use `@vonall/auth`'s `messageOf`.
8
+
9
+ ## 0.3.1
10
+
11
+ ### Patch Changes
12
+
13
+ - The code review's fixes: `svelte-sonner`, `bits-ui`, `@lucide/svelte` and `mode-watcher` are peer dependencies (one copy per app, so toasts reach the Product's Toaster); the login page no longer echoes unknown `?error=` codes and clears the URL's reason on a new attempt; every path goes through the base path; the settings page stays where the Product mounted it; copying the backup codes reports a failure.
14
+
3
15
  ## 0.3.0
4
16
 
5
17
  ### Minor Changes
package/README.md CHANGED
@@ -54,7 +54,7 @@ better-auth plugins and rate limit rule come from `@vonall/mcp` through `plugins
54
54
  <!-- src/routes/auth/signup/+page.svelte --> <RegisterForm />
55
55
  <!-- src/routes/auth/reset-password --> <ResetPasswordForm />
56
56
  <!-- src/routes/einladung/[token] --> <EinladungPage {data} tagline={APP_TAGLINE} />
57
- <!-- src/routes/home/settings --> <SettingsPage extraTabs={[agentsTab()]}>{#snippet extra(id)}…{/snippet}</SettingsPage>
57
+ <!-- src/routes/home/settings --> <SettingsPage extraTabs={[{ id: "agents", label, hint, icon: Bot }]}>{#snippet extra(id)}…{/snippet}</SettingsPage>
58
58
  <!-- src/routes/home/manage_user --> <AdminView currentUserId={data.currentUserId} />
59
59
  ```
60
60
 
@@ -57,6 +57,7 @@ export declare const en: {
57
57
  noNewAccountOpen: string;
58
58
  loginTooOld: string;
59
59
  confirmEmailFirst: string;
60
+ loginFailed: string;
60
61
  resetLinkNotSent: string;
61
62
  resetLinkSent: string;
62
63
  resetLinkNotRequested: string;
package/dist/messages.js CHANGED
@@ -63,6 +63,7 @@ export const en = {
63
63
  noNewAccountOpen: "Accounts can't be created at the moment. Please try again later.",
64
64
  loginTooOld: "Your login is more than a day old. Please log in again for this action.",
65
65
  confirmEmailFirst: "Confirm your email address first: we just sent you a new link.",
66
+ loginFailed: "Login failed.",
66
67
  resetLinkNotSent: "The reset link could not be sent.",
67
68
  resetLinkSent: "If an account with this email address exists, a link to reset the password has been sent.",
68
69
  resetLinkNotRequested: "The reset link could not be requested.",
@@ -306,6 +307,7 @@ export const de = {
306
307
  noNewAccountOpen: "Im Moment können keine Konten erstellt werden. Bitte versuch es später wieder.",
307
308
  loginTooOld: "Deine Anmeldung ist älter als einen Tag. Melde dich für diese Aktion bitte neu an.",
308
309
  confirmEmailFirst: "Bestätige zuerst deine E-Mail-Adresse: Wir haben dir gerade einen neuen Link geschickt.",
310
+ loginFailed: "Die Anmeldung hat nicht geklappt.",
309
311
  resetLinkNotSent: "Der Link zum Zurücksetzen konnte nicht verschickt werden.",
310
312
  resetLinkSent: "Wenn es ein Konto mit dieser E-Mail-Adresse gibt, ist ein Link zum Zurücksetzen des Passworts unterwegs.",
311
313
  resetLinkNotRequested: "Der Link zum Zurücksetzen konnte nicht angefordert werden.",
@@ -5,7 +5,6 @@ import { m } from "../messages.js";
5
5
  import { adminQuery, adminSchemaCommand, protectedCommand, protectedForm, protectedQuery, protectedSchemaCommand } from "../server/helpers.js";
6
6
  import { authInstance } from "../server/registry.js";
7
7
  import { UserRepo } from "../server/repositories/UserRepo.js";
8
- import { KontoService } from "../server/service/konto.service.js";
9
8
  import { emailSchema, nameSchema, passwordSchema } from "../types.js";
10
9
  import { isSessionNotFresh, reauthPath } from "../util/fresh.js";
11
10
  /** The Admin option: every account. */
@@ -80,12 +79,12 @@ export const deleteUser = adminSchemaCommand(z.object({ userId: z.string() }), a
80
79
  if (userId === admin.id) {
81
80
  throw error(400, { message: m().ownAccount, id: "own_account", timestamp: new Date().toISOString() });
82
81
  }
83
- await KontoService.loeschen(userId);
82
+ await UserRepo.kontoLoeschen(userId);
84
83
  await getAllUsers().refresh();
85
84
  return { success: true };
86
85
  });
87
86
  /** Deletes the user's own account with all its data. */
88
87
  export const deleteOwnAccount = protectedCommand(async (user) => {
89
- await KontoService.loeschen(user.id);
88
+ await UserRepo.kontoLoeschen(user.id);
90
89
  return { success: true };
91
90
  });
@@ -1,6 +1,11 @@
1
1
  /** The user's open Einladungen, and whether the server can send them by email. */
2
2
  export declare const getOffeneEinladungen: import("$app/server").RemoteQueryFunction<void, {
3
- einladungen: import("../types.js").OffeneEinladung[];
3
+ einladungen: {
4
+ id: string;
5
+ email: string | null;
6
+ expiresAt: Date;
7
+ createdAt: Date;
8
+ }[];
4
9
  mail: boolean;
5
10
  }, void>;
6
11
  /** Creates an Einladung; the link is returned only here. With an email, it is also sent (if mail is set up). */
@@ -3,6 +3,7 @@ import { z } from "zod";
3
3
  import { m } from "../messages.js";
4
4
  import { adminQuery, adminSchemaCommand, protectedQuery, protectedSchemaCommand } from "../server/helpers.js";
5
5
  import { registry } from "../server/registry.js";
6
+ import { EinladungRepo } from "../server/repositories/EinladungRepo.js";
6
7
  import { EinladungService } from "../server/service/einladung.service.js";
7
8
  import { sendEinladungEmail } from "../server/service/mail.js";
8
9
  import { emailSchema } from "../types.js";
@@ -15,7 +16,7 @@ function unwrap(result) {
15
16
  }
16
17
  /** The user's open Einladungen, and whether the server can send them by email. */
17
18
  export const getOffeneEinladungen = protectedQuery(async (user) => {
18
- return { einladungen: await EinladungService.offene(user.id), mail: registry().mailer.enabled };
19
+ return { einladungen: await EinladungRepo.listOpenBy(user.id, new Date()), mail: registry().mailer.enabled };
19
20
  });
20
21
  /** Creates an Einladung; the link is returned only here. With an email, it is also sent (if mail is set up). */
21
22
  export const createEinladung = protectedSchemaCommand(z.object({ email: emailSchema.nullable() }), async ({ email }, user) => {
@@ -47,10 +48,10 @@ export const withdrawEinladung = protectedSchemaCommand(z.object({ einladungId:
47
48
  });
48
49
  /** The Admin option: every open Einladung, and withdrawing any of them. */
49
50
  export const getAlleEinladungen = adminQuery(async () => {
50
- return await EinladungService.alleOffenen();
51
+ return await EinladungRepo.listAllOpen(new Date());
51
52
  });
52
53
  export const adminWithdrawEinladung = adminSchemaCommand(z.object({ einladungId: z.string() }), async ({ einladungId }) => {
53
- unwrap(await EinladungService.zurueckziehenAlsAdmin(einladungId));
54
+ unwrap(await EinladungService.zurueckziehen(null, einladungId));
54
55
  await getAlleEinladungen().refresh();
55
56
  await getOffeneEinladungen().refresh();
56
57
  });
@@ -5,5 +5,4 @@ export { authInstance, registry as authRegistry, type AuthLog, type Db } from ".
5
5
  export { EinladungRepo } from "./repositories/EinladungRepo.js";
6
6
  export { UserRepo } from "./repositories/UserRepo.js";
7
7
  export { EINLADUNG_COOKIE, EINLADUNG_GUELTIG_TAGE, EinladungService, type EinladungError } from "./service/einladung.service.js";
8
- export { KontoService } from "./service/konto.service.js";
9
8
  export { sendEinladungEmail, sendPasswordResetEmail, sendVerifyEmail } from "./service/mail.js";
@@ -5,5 +5,4 @@ export { authInstance, registry as authRegistry } from "./registry.js";
5
5
  export { EinladungRepo } from "./repositories/EinladungRepo.js";
6
6
  export { UserRepo } from "./repositories/UserRepo.js";
7
7
  export { EINLADUNG_COOKIE, EINLADUNG_GUELTIG_TAGE, EinladungService } from "./service/einladung.service.js";
8
- export { KontoService } from "./service/konto.service.js";
9
8
  export { sendEinladungEmail, sendPasswordResetEmail, sendVerifyEmail } from "./service/mail.js";
@@ -14,6 +14,9 @@ export declare const UserRepo: {
14
14
  providerId: string;
15
15
  updatedAt: Date;
16
16
  }[]>;
17
- /** Deletes the user; every row that belongs to them goes with it (foreign keys cascade). */
18
- deleteUser(userId: string): Promise<void>;
17
+ /**
18
+ * Deletes the Konto: the user with everything that belongs to them (the foreign keys of every user-owned table
19
+ * cascade, so a Product's own tables reference `user.id` with `onDelete: "cascade"`).
20
+ */
21
+ kontoLoeschen(userId: string): Promise<void>;
19
22
  };
@@ -59,8 +59,11 @@ export const UserRepo = {
59
59
  .from(schema.account)
60
60
  .where(eq(schema.account.userId, userId));
61
61
  },
62
- /** Deletes the user; every row that belongs to them goes with it (foreign keys cascade). */
63
- async deleteUser(userId) {
62
+ /**
63
+ * Deletes the Konto: the user with everything that belongs to them (the foreign keys of every user-owned table
64
+ * cascade, so a Product's own tables reference `user.id` with `onDelete: "cascade"`).
65
+ */
66
+ async kontoLoeschen(userId) {
64
67
  const { db, schema } = registry();
65
68
  await db.delete(schema.user).where(eq(schema.user.id, userId));
66
69
  },
@@ -1,5 +1,5 @@
1
1
  import { type Result } from "neverthrow";
2
- import type { EinladungInfo, OffeneEinladung, SignupMode, SignupState } from "../../types.js";
2
+ import type { EinladungInfo, SignupMode, SignupState } from "../../types.js";
3
3
  export declare const EINLADUNG_GUELTIG_TAGE = 14;
4
4
  /** The cookie that carries an Einladung's token from its link through signup (email, Google or GitHub). */
5
5
  export declare const EINLADUNG_COOKIE = "einladung";
@@ -33,16 +33,6 @@ export declare const EinladungService: {
33
33
  }>;
34
34
  /** Uses the Einladung for the new user: marks it as taken. */
35
35
  annehmen(userId: string, token: string, now?: Date): Promise<Result<void, EinladungError>>;
36
- offene(userId: string, now?: Date): Promise<OffeneEinladung[]>;
37
- /** Withdraws an open Einladung of the user. */
38
- zurueckziehen(userId: string, einladungId: string): Promise<Result<void, EinladungError>>;
39
- /** For the admin: every open Einladung, and withdrawing any of them. */
40
- alleOffenen(now?: Date): Promise<{
41
- id: string;
42
- email: string | null;
43
- inviterName: string;
44
- expiresAt: Date;
45
- createdAt: Date;
46
- }[]>;
47
- zurueckziehenAlsAdmin(einladungId: string): Promise<Result<void, EinladungError>>;
36
+ /** Withdraws an open Einladung of the user, or any open one when `userId` is null (the admin). */
37
+ zurueckziehen(userId: string | null, einladungId: string): Promise<Result<void, EinladungError>>;
48
38
  };
@@ -59,18 +59,8 @@ export const EinladungService = {
59
59
  return err(fehler.angenommen());
60
60
  return ok(undefined);
61
61
  },
62
- async offene(userId, now = new Date()) {
63
- return await EinladungRepo.listOpenBy(userId, now);
64
- },
65
- /** Withdraws an open Einladung of the user. */
62
+ /** Withdraws an open Einladung of the user, or any open one when `userId` is null (the admin). */
66
63
  async zurueckziehen(userId, einladungId) {
67
64
  return (await EinladungRepo.deleteOpen(einladungId, userId)) ? ok(undefined) : err(fehler.unbekannt());
68
65
  },
69
- /** For the admin: every open Einladung, and withdrawing any of them. */
70
- async alleOffenen(now = new Date()) {
71
- return await EinladungRepo.listAllOpen(now);
72
- },
73
- async zurueckziehenAlsAdmin(einladungId) {
74
- return (await EinladungRepo.deleteOpen(einladungId, null)) ? ok(undefined) : err(fehler.unbekannt());
75
- },
76
66
  };
@@ -8,6 +8,7 @@
8
8
  import { m } from "../messages.js";
9
9
  import { deleteOwnAccount } from "../remote/app_user.remote.js";
10
10
  import { authUi } from "./context.js";
11
+ import { href } from "./href.js";
11
12
  import { messageOf } from "../util/messageOf.js";
12
13
 
13
14
  /** Deleting the own account; `agents` adds the sentence about connected AI assistants (the MCP endpoint). */
@@ -27,7 +28,7 @@
27
28
  if (res.success) {
28
29
  toast.success(m().accountDeleted);
29
30
  await authClient.signOut().catch(() => undefined);
30
- window.location.href = "/";
31
+ window.location.href = href("/");
31
32
  } else {
32
33
  toast.error(m().accountNotDeleted);
33
34
  }
@@ -37,7 +37,8 @@
37
37
  const providers = $derived(page.data.providers as { github?: boolean; google?: boolean } | undefined);
38
38
  const mail = $derived(page.data.mail as boolean);
39
39
 
40
- const urlError = $derived(page.url.searchParams.get("error"));
40
+ // The reason the URL brought along (a refused social signup, a stale login); a fresh attempt replaces it
41
+ let urlError = $derived(page.url.searchParams.get("error"));
41
42
  // A new account through GitHub or Google was refused: why depends on the signup mode
42
43
  const noNewAccount = $derived(signupMode === "open" ? m().noNewAccountOpen : m().noNewAccountInvite);
43
44
  const URL_ERRORS = $derived<Record<string, string>>({
@@ -47,7 +48,8 @@
47
48
  session_not_fresh: m().loginTooOld,
48
49
  });
49
50
 
50
- const displayError = $derived(urlError ? (URL_ERRORS[urlError.toLowerCase()] ?? urlError) : errorMessage);
51
+ // Only known reasons are shown; an unknown code is not echoed (a link could plant any text here)
52
+ const displayError = $derived(urlError ? (URL_ERRORS[urlError.toLowerCase()] ?? m().loginFailed) : errorMessage);
51
53
 
52
54
  async function requestPasswordReset(e: SubmitEvent) {
53
55
  e.preventDefault();
@@ -57,7 +59,7 @@
57
59
  }
58
60
  sendingResetLink = true;
59
61
  try {
60
- const res = await authClient.requestPasswordReset({ email: forgotPasswordEmail, redirectTo: `${window.location.origin}/auth/reset-password` });
62
+ const res = await authClient.requestPasswordReset({ email: forgotPasswordEmail, redirectTo: `${window.location.origin}${href("/auth/reset-password")}` });
61
63
  if (res.error) {
62
64
  toast.error(res.error.message || m().resetLinkNotSent);
63
65
  } else {
@@ -84,7 +86,7 @@
84
86
  if (oauthRequest) {
85
87
  // Fallback: with a session, the authorize endpoint continues the flow in the browser
86
88
  const query = [...page.url.searchParams].filter(([key]) => key !== "sig" && key !== "exp");
87
- window.location.href = `/api/auth/oauth2/authorize?${query.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join("&")}`;
89
+ window.location.href = `${href("/api/auth/oauth2/authorize")}?${query.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join("&")}`;
88
90
  return;
89
91
  }
90
92
  // A new login: queries cached before it (one that sent here to log in again resolved empty) load again
@@ -99,6 +101,7 @@
99
101
  onRequest: () => {
100
102
  loading = true;
101
103
  errorMessage = "";
104
+ urlError = null;
102
105
  },
103
106
  onSuccess: async (ctx) => {
104
107
  loading = false;
@@ -169,8 +172,8 @@
169
172
  <div class="grid gap-6">
170
173
  {#if providers?.github || providers?.google}
171
174
  <div class="flex flex-col gap-4">
172
- {#if providers.github}<SocialButton provider="github" callbackURL={redirectTo ?? "/home"} />{/if}
173
- {#if providers.google}<SocialButton provider="google" callbackURL={redirectTo ?? "/home"} />{/if}
175
+ {#if providers.github}<SocialButton provider="github" callbackURL={redirectTo ?? href("/home")} />{/if}
176
+ {#if providers.google}<SocialButton provider="google" callbackURL={redirectTo ?? href("/home")} />{/if}
174
177
  </div>
175
178
  <div class="after:border-border relative text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-t">
176
179
  <span class="bg-card text-muted-foreground relative z-10 px-2">{m().orWithEmail}</span>
@@ -39,7 +39,7 @@
39
39
  }
40
40
  const name = email.split("@")[0]; // the server turns it into a readable name
41
41
  await authClient.signUp.email(
42
- { email, password, name, callbackURL: "/home" },
42
+ { email, password, name, callbackURL: href("/home") },
43
43
  {
44
44
  onRequest: () => {
45
45
  loading = true;
@@ -8,7 +8,6 @@
8
8
  import type { AppUserClient } from "../types.js";
9
9
  import { authUi } from "./context.js";
10
10
  import DeletionTab from "./DeletionTab.svelte";
11
- import { href } from "./href.js";
12
11
  import InvitationsTab from "./InvitationsTab.svelte";
13
12
  import LinksTab from "./LinksTab.svelte";
14
13
  import MobileSettingsMenu from "./MobileSettingsMenu.svelte";
@@ -48,7 +47,7 @@
48
47
  const url = new URL(page.url.href);
49
48
  if (tabName) url.searchParams.set("tab", tabName);
50
49
  else url.searchParams.delete("tab");
51
- goto(`${href("/home/settings")}${url.search}`);
50
+ goto(`${page.url.pathname}${url.search}`); // wherever the Product mounted this page
52
51
  }
53
52
 
54
53
  const memberSince = $derived(user.createdAt ? new Date(user.createdAt).toLocaleDateString(locale, { year: "numeric", month: "long" }) : "");
@@ -2,16 +2,17 @@
2
2
  import { Button } from "../components/ui/button/index.js";
3
3
  import { m } from "../messages.js";
4
4
  import { authUi } from "./context.js";
5
+ import { href } from "./href.js";
5
6
  import IconGithub from "./IconGithub.svelte";
6
7
  import IconGoogle from "./IconGoogle.svelte";
7
8
 
8
9
  /** "Continue with GitHub/Google": starts the social sign-in with the Product's client. */
9
- let { provider, callbackURL = "/home" }: { provider: "github" | "google"; callbackURL?: string } = $props();
10
+ let { provider, callbackURL = href("/home") }: { provider: "github" | "google"; callbackURL?: string } = $props();
10
11
  const { authClient } = authUi();
11
12
  const names = { github: "GitHub", google: "Google" };
12
13
 
13
14
  async function login() {
14
- await authClient.signIn.social({ provider, callbackURL, errorCallbackURL: "/auth/login" });
15
+ await authClient.signIn.social({ provider, callbackURL, errorCallbackURL: href("/auth/login") });
15
16
  }
16
17
  </script>
17
18
 
@@ -98,11 +98,15 @@
98
98
  }
99
99
  }
100
100
 
101
- function handleCopyBackupCodes() {
102
- navigator.clipboard.writeText(backupCodes.join("\n"));
103
- isCopied = true;
104
- toast.success(m().backupCodesCopied);
105
- setTimeout(() => (isCopied = false), 2000);
101
+ async function handleCopyBackupCodes() {
102
+ try {
103
+ await navigator.clipboard.writeText(backupCodes.join("\n"));
104
+ isCopied = true;
105
+ toast.success(m().backupCodesCopied);
106
+ setTimeout(() => (isCopied = false), 2000);
107
+ } catch {
108
+ toast.error(m().copyLinkFailed);
109
+ }
106
110
  }
107
111
  </script>
108
112
 
@@ -12,7 +12,7 @@ export function isSessionNotFresh(e) {
12
12
  }
13
13
  /** The login page that signs out a stale session and then returns to `redirectTo`. */
14
14
  export function reauthPath(redirectTo) {
15
- return `/auth/login?error=${SESSION_NOT_FRESH.toLowerCase()}&redirectTo=${encodeURIComponent(redirectTo)}`;
15
+ return `/auth/login?error=${SESSION_NOT_FRESH.toLowerCase()}&redirectTo=${encodeURIComponent(redirectTo)}`; // the Product's loads prefix the base path
16
16
  }
17
17
  /** better-auth's own check: a `freshAge` of 0 turns it off. */
18
18
  export function isStale(createdAt, freshAgeSeconds, now = new Date()) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vonall/auth",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Accounts for a Web App Factory Product: better-auth with the hooks, the protected helpers, Einladungen, Konto, the auth pages and the settings; Admin as an option",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -58,7 +58,10 @@
58
58
  "neverthrow": "^8.2.0",
59
59
  "pg": "^8.23.1",
60
60
  "svelte": "^5.57.2",
61
- "zod": "^4.6.7"
61
+ "zod": "^4.6.7",
62
+ "svelte-sonner": "^1.2.1",
63
+ "bits-ui": "^2.19.5",
64
+ "@lucide/svelte": "^1.22.0"
62
65
  },
63
66
  "devDependencies": {
64
67
  "@better-auth/mcp": "^1.7.12",
@@ -78,7 +81,10 @@
78
81
  "typescript": "^6.0.3",
79
82
  "vite": "8.3.4",
80
83
  "vitest": "^5.0.5",
81
- "zod": "^4.6.7"
84
+ "zod": "^4.6.7",
85
+ "svelte-sonner": "^1.2.1",
86
+ "bits-ui": "^2.19.5",
87
+ "@lucide/svelte": "^1.22.0"
82
88
  },
83
89
  "imports": {
84
90
  "#lib": "./src/lib/index.js",
@@ -91,11 +97,8 @@
91
97
  "access": "public"
92
98
  },
93
99
  "dependencies": {
94
- "@lucide/svelte": "1.22.0",
95
100
  "better-auth": "^1.7.12",
96
- "bits-ui": "^2.20.2",
97
101
  "clsx": "^2.1.1",
98
- "svelte-sonner": "^1.2.1",
99
102
  "tailwind-merge": "^3.7.0",
100
103
  "tailwind-variants": "^3.3.1",
101
104
  "uqr": "^0.1.3",
@@ -1,7 +0,0 @@
1
- export declare const KontoService: {
2
- /**
3
- * Deletes the account with everything that belongs to it: the foreign keys of every user-owned table cascade, so a
4
- * Product's own tables reference `user.id` with `onDelete: "cascade"`.
5
- */
6
- loeschen(userId: string): Promise<void>;
7
- };
@@ -1,11 +0,0 @@
1
- /** The user's account as a whole. */
2
- import { UserRepo } from "../repositories/UserRepo.js";
3
- export const KontoService = {
4
- /**
5
- * Deletes the account with everything that belongs to it: the foreign keys of every user-owned table cascade, so a
6
- * Product's own tables reference `user.id` with `onDelete: "cascade"`.
7
- */
8
- async loeschen(userId) {
9
- await UserRepo.deleteUser(userId);
10
- },
11
- };