@vonall/auth 0.2.1 → 0.3.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.
Files changed (108) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +22 -0
  3. package/dist/components/ui/avatar/avatar-fallback.svelte +17 -0
  4. package/dist/components/ui/avatar/avatar-fallback.svelte.d.ts +4 -0
  5. package/dist/components/ui/avatar/avatar-image.svelte +17 -0
  6. package/dist/components/ui/avatar/avatar-image.svelte.d.ts +4 -0
  7. package/dist/components/ui/avatar/avatar.svelte +17 -0
  8. package/dist/components/ui/avatar/avatar.svelte.d.ts +4 -0
  9. package/dist/components/ui/avatar/index.d.ts +4 -0
  10. package/dist/components/ui/avatar/index.js +6 -0
  11. package/dist/components/ui/button/button.svelte +82 -0
  12. package/dist/components/ui/button/button.svelte.d.ts +70 -0
  13. package/dist/components/ui/button/index.d.ts +2 -0
  14. package/dist/components/ui/button/index.js +2 -0
  15. package/dist/components/ui/card/card-action.svelte +20 -0
  16. package/dist/components/ui/card/card-action.svelte.d.ts +5 -0
  17. package/dist/components/ui/card/card-content.svelte +15 -0
  18. package/dist/components/ui/card/card-content.svelte.d.ts +5 -0
  19. package/dist/components/ui/card/card-description.svelte +20 -0
  20. package/dist/components/ui/card/card-description.svelte.d.ts +5 -0
  21. package/dist/components/ui/card/card-footer.svelte +20 -0
  22. package/dist/components/ui/card/card-footer.svelte.d.ts +5 -0
  23. package/dist/components/ui/card/card-header.svelte +23 -0
  24. package/dist/components/ui/card/card-header.svelte.d.ts +5 -0
  25. package/dist/components/ui/card/card-title.svelte +20 -0
  26. package/dist/components/ui/card/card-title.svelte.d.ts +5 -0
  27. package/dist/components/ui/card/card.svelte +23 -0
  28. package/dist/components/ui/card/card.svelte.d.ts +5 -0
  29. package/dist/components/ui/card/index.d.ts +8 -0
  30. package/dist/components/ui/card/index.js +10 -0
  31. package/dist/components/ui/input/index.d.ts +2 -0
  32. package/dist/components/ui/input/index.js +4 -0
  33. package/dist/components/ui/input/input.svelte +48 -0
  34. package/dist/components/ui/input/input.svelte.d.ts +13 -0
  35. package/dist/components/ui/label/index.d.ts +2 -0
  36. package/dist/components/ui/label/index.js +4 -0
  37. package/dist/components/ui/label/label.svelte +20 -0
  38. package/dist/components/ui/label/label.svelte.d.ts +4 -0
  39. package/dist/components/ui/tabs/index.d.ts +5 -0
  40. package/dist/components/ui/tabs/index.js +7 -0
  41. package/dist/components/ui/tabs/tabs-content.svelte +17 -0
  42. package/dist/components/ui/tabs/tabs-content.svelte.d.ts +4 -0
  43. package/dist/components/ui/tabs/tabs-list.svelte +20 -0
  44. package/dist/components/ui/tabs/tabs-list.svelte.d.ts +4 -0
  45. package/dist/components/ui/tabs/tabs-trigger.svelte +20 -0
  46. package/dist/components/ui/tabs/tabs-trigger.svelte.d.ts +4 -0
  47. package/dist/components/ui/tabs/tabs.svelte +19 -0
  48. package/dist/components/ui/tabs/tabs.svelte.d.ts +4 -0
  49. package/dist/messages.d.ts +197 -0
  50. package/dist/messages.js +406 -0
  51. package/dist/ui/AdminView.svelte +91 -0
  52. package/dist/ui/AdminView.svelte.d.ts +6 -0
  53. package/dist/ui/ChangePasswordCard.svelte +62 -0
  54. package/dist/ui/ChangePasswordCard.svelte.d.ts +3 -0
  55. package/dist/ui/DeletionTab.svelte +77 -0
  56. package/dist/ui/DeletionTab.svelte.d.ts +6 -0
  57. package/dist/ui/EinladungErstellen.svelte +74 -0
  58. package/dist/ui/EinladungErstellen.svelte.d.ts +3 -0
  59. package/dist/ui/EinladungPage.svelte +42 -0
  60. package/dist/ui/EinladungPage.svelte.d.ts +13 -0
  61. package/dist/ui/IconGithub.svelte +23 -0
  62. package/dist/ui/IconGithub.svelte.d.ts +4 -0
  63. package/dist/ui/IconGoogle.svelte +16 -0
  64. package/dist/ui/IconGoogle.svelte.d.ts +4 -0
  65. package/dist/ui/InvitationsTab.svelte +46 -0
  66. package/dist/ui/InvitationsTab.svelte.d.ts +3 -0
  67. package/dist/ui/LinksTab.svelte +91 -0
  68. package/dist/ui/LinksTab.svelte.d.ts +3 -0
  69. package/dist/ui/LoginForm.svelte +223 -0
  70. package/dist/ui/LoginForm.svelte.d.ts +8 -0
  71. package/dist/ui/MobileSettingsMenu.svelte +37 -0
  72. package/dist/ui/MobileSettingsMenu.svelte.d.ts +11 -0
  73. package/dist/ui/ProfileManagementTab.svelte +56 -0
  74. package/dist/ui/ProfileManagementTab.svelte.d.ts +7 -0
  75. package/dist/ui/RegisterForm.svelte +143 -0
  76. package/dist/ui/RegisterForm.svelte.d.ts +6 -0
  77. package/dist/ui/ResetPasswordForm.svelte +91 -0
  78. package/dist/ui/ResetPasswordForm.svelte.d.ts +3 -0
  79. package/dist/ui/SecurityTab.svelte +25 -0
  80. package/dist/ui/SecurityTab.svelte.d.ts +3 -0
  81. package/dist/ui/SessionsTab.svelte +98 -0
  82. package/dist/ui/SessionsTab.svelte.d.ts +3 -0
  83. package/dist/ui/SetPasswordCard.svelte +56 -0
  84. package/dist/ui/SetPasswordCard.svelte.d.ts +6 -0
  85. package/dist/ui/SettingsHeader.svelte +19 -0
  86. package/dist/ui/SettingsHeader.svelte.d.ts +7 -0
  87. package/dist/ui/SettingsPage.svelte +95 -0
  88. package/dist/ui/SettingsPage.svelte.d.ts +9 -0
  89. package/dist/ui/SettingsTabsList.svelte +17 -0
  90. package/dist/ui/SettingsTabsList.svelte.d.ts +7 -0
  91. package/dist/ui/SocialButton.svelte +22 -0
  92. package/dist/ui/SocialButton.svelte.d.ts +7 -0
  93. package/dist/ui/TwoFactorAuthenticationCard.svelte +220 -0
  94. package/dist/ui/TwoFactorAuthenticationCard.svelte.d.ts +3 -0
  95. package/dist/ui/Verify2FAForm.svelte +63 -0
  96. package/dist/ui/Verify2FAForm.svelte.d.ts +11 -0
  97. package/dist/ui/context.d.ts +93 -0
  98. package/dist/ui/context.js +17 -0
  99. package/dist/ui/href.d.ts +2 -0
  100. package/dist/ui/href.js +5 -0
  101. package/dist/ui/index.d.ts +10 -0
  102. package/dist/ui/index.js +11 -0
  103. package/dist/ui/tabs.d.ts +17 -0
  104. package/dist/ui/tabs.js +21 -0
  105. package/dist/util/fresh.js +1 -1
  106. package/dist/utils.d.ts +12 -0
  107. package/dist/utils.js +6 -0
  108. package/package.json +18 -3
@@ -0,0 +1,56 @@
1
+ <script lang="ts">
2
+ import { toast } from "svelte-sonner";
3
+ import { Button } from "../components/ui/button/index.js";
4
+ import { Input } from "../components/ui/input/index.js";
5
+ import { Label } from "../components/ui/label/index.js";
6
+ import * as Tabs from "../components/ui/tabs/index.js";
7
+ import { m } from "../messages.js";
8
+ import { updateUserProfile } from "../remote/app_user.remote.js";
9
+ import type { AppUserClient } from "../types.js";
10
+ import { messageOf } from "../util/messageOf.js";
11
+
12
+ let { user }: { user: AppUserClient | null | undefined } = $props();
13
+ let isSubmitting = $state(false);
14
+
15
+ $effect(() => {
16
+ if (user) {
17
+ updateUserProfile.fields.name.set(user.name);
18
+ updateUserProfile.fields.email.set(user.email);
19
+ }
20
+ });
21
+ </script>
22
+
23
+ <Tabs.Content value="profile" class="panel mt-6 p-6 md:p-8">
24
+ <form
25
+ class="space-y-6"
26
+ {...updateUserProfile.enhance(async ({ submit }) => {
27
+ isSubmitting = true;
28
+ try {
29
+ // submit() resolves false when validation failed; the field issues show why
30
+ if (await submit()) toast.success(m().profileUpdated);
31
+ } catch (e) {
32
+ toast.error(messageOf(e, m().profileNotUpdated));
33
+ } finally {
34
+ isSubmitting = false;
35
+ }
36
+ })}
37
+ >
38
+ <div class="space-y-2">
39
+ <Label for="name">{m().name}</Label>
40
+ <Input id="name" {...updateUserProfile.fields.name.as("text")} placeholder={m().yourName} autocomplete="name" />
41
+ {#each updateUserProfile.fields.name.issues() as issue (issue.message)}
42
+ <p class="text-sm text-destructive">{issue.message}</p>
43
+ {/each}
44
+ </div>
45
+
46
+ <div class="space-y-2">
47
+ <Label for="email">{m().email}</Label>
48
+ <Input id="email" type="email" {...updateUserProfile.fields.email.as("text")} placeholder={m().emailPlaceholder} autocomplete="email" />
49
+ {#each updateUserProfile.fields.email.issues() as issue (issue.message)}
50
+ <p class="text-sm text-destructive">{issue.message}</p>
51
+ {/each}
52
+ </div>
53
+
54
+ <Button type="submit" class="cursor-pointer" disabled={isSubmitting}>{isSubmitting ? m().saving : m().save}</Button>
55
+ </form>
56
+ </Tabs.Content>
@@ -0,0 +1,7 @@
1
+ import type { AppUserClient } from "../types.js";
2
+ type $$ComponentProps = {
3
+ user: AppUserClient | null | undefined;
4
+ };
5
+ declare const ProfileManagementTab: import("svelte").Component<$$ComponentProps, {}, "">;
6
+ type ProfileManagementTab = ReturnType<typeof ProfileManagementTab>;
7
+ export default ProfileManagementTab;
@@ -0,0 +1,143 @@
1
+ <script lang="ts">
2
+ import { LoaderCircle } from "@lucide/svelte";
3
+ import { goto } from "$app/navigation";
4
+ import { page } from "$app/state";
5
+ import { Button } from "../components/ui/button/index.js";
6
+ import * as Card from "../components/ui/card/index.js";
7
+ import { Input } from "../components/ui/input/index.js";
8
+ import { Label } from "../components/ui/label/index.js";
9
+ import { m } from "../messages.js";
10
+ import type { SignupState } from "../types.js";
11
+ import { authUi } from "./context.js";
12
+ import { href } from "./href.js";
13
+ import SocialButton from "./SocialButton.svelte";
14
+ import { cn } from "../utils.js";
15
+
16
+ /** The signup page: what `signupLoad` allows (the signup mode, the Einladung), then email and password or a social login. */
17
+ const { class: className, ...restProps }: { class?: string } = $props();
18
+ const { authClient, appName } = authUi();
19
+
20
+ let email = $state("");
21
+ let password = $state("");
22
+ let password_repeat = $state("");
23
+ let loading = $state(false);
24
+ let errorMessage = $state("");
25
+
26
+ const providers = $derived(page.data.providers as { github?: boolean; google?: boolean } | undefined);
27
+ const signupState = $derived(page.data.state as SignupState);
28
+ const erlaubt = $derived(signupState === "allowed");
29
+ /** After signing up without a session (signup mode open): confirm by mail first, or (first account) log in. */
30
+ let done = $state<"confirm" | "login" | null>(null);
31
+ const einladung = $derived(page.data.einladung as { inviterName: string } | null);
32
+ const providerNames = $derived([providers?.github && "GitHub", providers?.google && "Google"].filter(Boolean).join(` ${m().or} `));
33
+
34
+ async function signUp(e: SubmitEvent) {
35
+ e.preventDefault();
36
+ if (password !== password_repeat) {
37
+ errorMessage = m().passwordsMismatch;
38
+ return;
39
+ }
40
+ const name = email.split("@")[0]; // the server turns it into a readable name
41
+ await authClient.signUp.email(
42
+ { email, password, name, callbackURL: href("/home") },
43
+ {
44
+ onRequest: () => {
45
+ loading = true;
46
+ errorMessage = "";
47
+ },
48
+ onSuccess: async (ctx) => {
49
+ loading = false;
50
+ const data = ctx.data as { token?: string | null; user?: { emailVerified?: boolean } } | null;
51
+ // No session yet when the email address has to be confirmed first (signup mode open)
52
+ if (!data?.token) done = data?.user?.emailVerified ? "login" : "confirm";
53
+ else await goto(href("/home"), { replaceState: true });
54
+ },
55
+ onError: async (ctx) => {
56
+ errorMessage =
57
+ ctx.error.status === 403 ? m().needsValidEinladung : ctx.error.message?.toLowerCase().includes("exist") ? m().emailInUse : ctx.error.message || m().signupFailed;
58
+ loading = false;
59
+ },
60
+ },
61
+ );
62
+ }
63
+ </script>
64
+
65
+ <div class={cn("flex flex-col gap-6", className)} {...restProps}>
66
+ <Card.Root>
67
+ <Card.Header class="text-center">
68
+ <Card.Title class="text-xl">{m().createAccount}</Card.Title>
69
+ {#if einladung}
70
+ <Card.Description>{m().invitesYouTo(einladung.inviterName, appName)}</Card.Description>
71
+ {/if}
72
+ {#if erlaubt && (providers?.github || providers?.google)}
73
+ <Card.Description>{m().signUpWith(providerNames)}</Card.Description>
74
+ {/if}
75
+ </Card.Header>
76
+ <Card.Content>
77
+ {#if done === "confirm"}
78
+ <div class="grid gap-4 text-center text-sm">
79
+ <p>{m().almostDone(email, appName)}</p>
80
+ </div>
81
+ {:else if done === "login"}
82
+ <div class="grid gap-4 text-center text-sm">
83
+ <p>{m().accountReady}</p>
84
+ <p><a href={href("/auth/login")} class="underline underline-offset-4">{m().logIn}</a></p>
85
+ </div>
86
+ {:else if !erlaubt}
87
+ <div class="grid gap-4 text-center text-sm">
88
+ {#if signupState === "no_mail"}
89
+ <p>{m().noNewAccountOpen}</p>
90
+ {:else}
91
+ <p>{m().inviteOnly(appName)}</p>
92
+ {/if}
93
+ <p>
94
+ {m().alreadyHaveAccount}
95
+ <a href={href("/auth/login")} class="underline underline-offset-4">{m().logIn}</a>
96
+ </p>
97
+ </div>
98
+ {:else}
99
+ <form onsubmit={signUp}>
100
+ <div class="grid gap-6">
101
+ {#if providers?.github || providers?.google}
102
+ <div class="flex flex-col gap-4">
103
+ {#if providers.github}<SocialButton provider="github" />{/if}
104
+ {#if providers.google}<SocialButton provider="google" />{/if}
105
+ </div>
106
+ <div class="relative text-center text-sm">
107
+ <span class="relative z-10 bg-card px-2 text-muted-foreground">{m().orWithEmail}</span>
108
+ <div class="absolute inset-0 top-1/2 z-0 border-t border-border"></div>
109
+ </div>
110
+ {/if}
111
+
112
+ <div class="grid gap-3">
113
+ <Label for="email">{m().email}</Label>
114
+ <Input id="email" name="email" type="email" bind:value={email} required autocomplete="username" />
115
+ </div>
116
+ <div class="grid gap-3">
117
+ <Label for="password">{m().password}</Label>
118
+ <Input id="password" name="password" type="password" required bind:value={password} autocomplete="new-password" />
119
+ </div>
120
+ <div class="grid gap-3">
121
+ <Label for="password_repeat">{m().repeatPassword}</Label>
122
+ <Input id="password_repeat" name="password_repeat" type="password" required bind:value={password_repeat} autocomplete="new-password" />
123
+ </div>
124
+
125
+ {#if errorMessage}
126
+ <div class="rounded-md text-center text-sm text-destructive">{errorMessage}</div>
127
+ {/if}
128
+
129
+ <Button type="submit" class="w-full cursor-pointer" disabled={loading}>
130
+ {#if loading}<LoaderCircle class="mr-2 h-4 w-4 animate-spin" />{/if}
131
+ {m().signUp}
132
+ </Button>
133
+
134
+ <div class="text-center text-sm">
135
+ {m().alreadyHaveAccount}
136
+ <a href={href("/auth/login")} class="underline underline-offset-4">{m().logIn}</a>
137
+ </div>
138
+ </div>
139
+ </form>
140
+ {/if}
141
+ </Card.Content>
142
+ </Card.Root>
143
+ </div>
@@ -0,0 +1,6 @@
1
+ type $$ComponentProps = {
2
+ class?: string;
3
+ };
4
+ declare const RegisterForm: import("svelte").Component<$$ComponentProps, {}, "">;
5
+ type RegisterForm = ReturnType<typeof RegisterForm>;
6
+ export default RegisterForm;
@@ -0,0 +1,91 @@
1
+ <script lang="ts">
2
+ import { LoaderCircle } from "@lucide/svelte";
3
+ import { goto } from "$app/navigation";
4
+ import { page } from "$app/state";
5
+ import { toast } from "svelte-sonner";
6
+ import { Button } from "../components/ui/button/index.js";
7
+ import * as Card from "../components/ui/card/index.js";
8
+ import { Input } from "../components/ui/input/index.js";
9
+ import { Label } from "../components/ui/label/index.js";
10
+ import { m } from "../messages.js";
11
+ import { MIN_PASSWORD_LENGTH } from "../types.js";
12
+ import { authUi } from "./context.js";
13
+ import { href } from "./href.js";
14
+ import { messageOf } from "../util/messageOf.js";
15
+
16
+ /** The page the reset mail links to (`?token=`): the new password, twice. */
17
+ const { authClient } = authUi();
18
+
19
+ let password = $state("");
20
+ let confirmPassword = $state("");
21
+ let loading = $state(false);
22
+ let errorMessage = $state("");
23
+
24
+ const token = $derived(page.url.searchParams.get("token"));
25
+
26
+ async function handleResetPassword(e: SubmitEvent) {
27
+ e.preventDefault();
28
+ if (!token) {
29
+ toast.error(m().resetLinkInvalid);
30
+ return;
31
+ }
32
+ if (password.length < MIN_PASSWORD_LENGTH) {
33
+ toast.error(m().passwordTooShortDot(MIN_PASSWORD_LENGTH));
34
+ return;
35
+ }
36
+ if (password !== confirmPassword) {
37
+ toast.error(m().passwordsDontMatchDot);
38
+ return;
39
+ }
40
+ loading = true;
41
+ errorMessage = "";
42
+ try {
43
+ const res = await authClient.resetPassword({ newPassword: password, token });
44
+ if (res.error) {
45
+ errorMessage = res.error.message || m().passwordNotReset;
46
+ toast.error(errorMessage);
47
+ } else {
48
+ toast.success(m().passwordWasReset);
49
+ await goto(href("/auth/login"), { replaceState: true });
50
+ }
51
+ } catch (err) {
52
+ errorMessage = messageOf(err, m().somethingWentWrong);
53
+ toast.error(errorMessage);
54
+ } finally {
55
+ loading = false;
56
+ }
57
+ }
58
+ </script>
59
+
60
+ <Card.Root>
61
+ <Card.Header class="text-center">
62
+ <Card.Title class="text-xl">{m().resetTitle}</Card.Title>
63
+ <Card.Description>{m().resetIntro}</Card.Description>
64
+ </Card.Header>
65
+ <Card.Content>
66
+ {#if !token}
67
+ <div class="rounded-md border border-destructive/20 bg-destructive/10 p-4 text-center text-sm text-destructive">{m().resetNoToken}</div>
68
+ <Button variant="outline" class="mt-4 w-full cursor-pointer" onclick={() => goto(href("/auth/login"))}>{m().backToLogin}</Button>
69
+ {:else}
70
+ <form onsubmit={handleResetPassword}>
71
+ <div class="grid gap-6">
72
+ <div class="grid gap-3">
73
+ <Label for="password">{m().newPassword}</Label>
74
+ <Input id="password" type="password" bind:value={password} placeholder="••••••••" required autocomplete="new-password" />
75
+ </div>
76
+ <div class="grid gap-3">
77
+ <Label for="confirmPassword">{m().confirmNewPassword}</Label>
78
+ <Input id="confirmPassword" type="password" bind:value={confirmPassword} placeholder="••••••••" required autocomplete="new-password" />
79
+ </div>
80
+ {#if errorMessage}
81
+ <div class="rounded-md text-center text-sm text-destructive">{errorMessage}</div>
82
+ {/if}
83
+ <Button type="submit" class="w-full cursor-pointer" disabled={loading}>
84
+ {#if loading}<LoaderCircle class="mr-2 h-4 w-4 animate-spin" />{/if}
85
+ {m().savePassword}
86
+ </Button>
87
+ </div>
88
+ </form>
89
+ {/if}
90
+ </Card.Content>
91
+ </Card.Root>
@@ -0,0 +1,3 @@
1
+ declare const ResetPasswordForm: import("svelte").Component<Record<string, never>, {}, "">;
2
+ type ResetPasswordForm = ReturnType<typeof ResetPasswordForm>;
3
+ export default ResetPasswordForm;
@@ -0,0 +1,25 @@
1
+ <script lang="ts">
2
+ import * as Tabs from "../components/ui/tabs/index.js";
3
+ import { m } from "../messages.js";
4
+ import { getLinkedAccounts } from "../remote/app_user.remote.js";
5
+ import ChangePasswordCard from "./ChangePasswordCard.svelte";
6
+ import SetPasswordCard from "./SetPasswordCard.svelte";
7
+ import TwoFactorAuthenticationCard from "./TwoFactorAuthenticationCard.svelte";
8
+
9
+ const linkedAccounts = $derived(getLinkedAccounts());
10
+ const hasPasswordAccount = $derived(linkedAccounts.current?.some((acc) => acc.providerId === "credential"));
11
+ </script>
12
+
13
+ <Tabs.Content value="security" class="mt-6 space-y-6">
14
+ {#if linkedAccounts.current}
15
+ {#if hasPasswordAccount}
16
+ <ChangePasswordCard />
17
+ <TwoFactorAuthenticationCard />
18
+ {:else}
19
+ <!-- Accounts that came through GitHub or Google first set a password -->
20
+ <SetPasswordCard onSetSuccess={() => linkedAccounts.refresh()} />
21
+ {/if}
22
+ {:else}
23
+ <div class="panel p-8 text-center text-muted-foreground">{m().loadingSecurity}</div>
24
+ {/if}
25
+ </Tabs.Content>
@@ -0,0 +1,3 @@
1
+ declare const SecurityTab: import("svelte").Component<Record<string, never>, {}, "">;
2
+ type SecurityTab = ReturnType<typeof SecurityTab>;
3
+ export default SecurityTab;
@@ -0,0 +1,98 @@
1
+ <script lang="ts">
2
+ import { Globe, LogOut, Monitor, Smartphone, Tablet, X } from "@lucide/svelte";
3
+ import { page } from "$app/state";
4
+ import { toast } from "svelte-sonner";
5
+ import { Button } from "../components/ui/button/index.js";
6
+ import * as Tabs from "../components/ui/tabs/index.js";
7
+ import { m } from "../messages.js";
8
+ import { getSessions, revokeOthers, revokeSessionById } from "../remote/app_user.remote.js";
9
+ import { authUi } from "./context.js";
10
+ import { messageOf } from "../util/messageOf.js";
11
+
12
+ /** The devices the user is logged in on. Only mounted while open: listing them needs a login from the last day. */
13
+ const { locale } = authUi();
14
+ const sessions = $derived(await getSessions());
15
+ const currentSessionId = $derived((page.data.session as { id?: string } | null | undefined)?.id);
16
+
17
+ async function handleRevoke(sessionId: string) {
18
+ try {
19
+ await revokeSessionById({ sessionId });
20
+ toast.success(m().sessionEnded);
21
+ } catch (e) {
22
+ toast.error(messageOf(e, m().sessionNotEnded));
23
+ }
24
+ }
25
+
26
+ async function handleRevokeOthers() {
27
+ try {
28
+ await revokeOthers();
29
+ toast.success(m().otherSessionsEnded);
30
+ } catch (e) {
31
+ toast.error(messageOf(e, m().otherSessionsNotEnded));
32
+ }
33
+ }
34
+
35
+ function getDeviceIcon(session: { userAgent?: string | null }) {
36
+ const ua = session.userAgent?.toLowerCase() || "";
37
+ if (ua.includes("mobile") || ua.includes("iphone") || ua.includes("android")) return Smartphone;
38
+ if (ua.includes("tablet") || ua.includes("ipad")) return Tablet;
39
+ return Monitor;
40
+ }
41
+
42
+ function formatLastActive(date: string | Date) {
43
+ if (!date) return m().unknown;
44
+ return new Date(date).toLocaleString(locale, { day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit" });
45
+ }
46
+ </script>
47
+
48
+ <Tabs.Content value="sessions" class="panel mt-6 p-6 md:p-8">
49
+ <div class="space-y-6">
50
+ <div class="flex items-center justify-between">
51
+ <div>
52
+ <h3 class="text-lg font-semibold">{m().activeSessions}</h3>
53
+ <p class="text-sm text-muted-foreground">{m().devicesLoggedIn}</p>
54
+ </div>
55
+ <Button variant="destructive" size="sm" onclick={handleRevokeOthers} class="flex cursor-pointer gap-2">
56
+ <LogOut class="size-4" />
57
+ {m().endAllOthers}
58
+ </Button>
59
+ </div>
60
+
61
+ <div class="divide-y overflow-hidden rounded-lg border">
62
+ {#if sessions.length === 0}
63
+ <div class="p-8 text-center text-muted-foreground">{m().noActiveSessions}</div>
64
+ {:else}
65
+ {#each sessions as s (s.id)}
66
+ {@const DeviceIcon = getDeviceIcon(s)}
67
+ <div class="flex items-center justify-between bg-muted/10 p-4">
68
+ <div class="flex items-center gap-4">
69
+ <div class="rounded-full bg-muted p-2">
70
+ <DeviceIcon class="size-5 text-muted-foreground" />
71
+ </div>
72
+ <div>
73
+ <div class="flex items-center gap-2">
74
+ <!-- Sessions don't record browser and OS yet -->
75
+ <span class="font-medium">{m().unknownBrowser}</span>
76
+ {#if s.id === currentSessionId}
77
+ <span class="rounded-full bg-green-100 px-2 py-0.5 text-[10px] font-bold text-green-700 uppercase dark:bg-green-900/30 dark:text-green-400">{m().thisSession}</span>
78
+ {/if}
79
+ </div>
80
+ <div class="mt-0.5 flex items-center gap-2 text-xs text-muted-foreground">
81
+ <Globe class="size-3" />
82
+ <span>{s.ipAddress || m().noIpAddress}</span>
83
+ </div>
84
+ <p class="mt-1 text-[11px] text-muted-foreground/80">{m().lastActive(formatLastActive(s.updatedAt || s.createdAt))}</p>
85
+ </div>
86
+ </div>
87
+
88
+ {#if s.id !== currentSessionId}
89
+ <Button variant="ghost" size="icon" class="cursor-pointer text-destructive hover:bg-destructive/10" onclick={() => handleRevoke(s.token)}>
90
+ <X class="size-4" />
91
+ </Button>
92
+ {/if}
93
+ </div>
94
+ {/each}
95
+ {/if}
96
+ </div>
97
+ </div>
98
+ </Tabs.Content>
@@ -0,0 +1,3 @@
1
+ declare const SessionsTab: import("svelte").Component<Record<string, never>, {}, "">;
2
+ type SessionsTab = ReturnType<typeof SessionsTab>;
3
+ export default SessionsTab;
@@ -0,0 +1,56 @@
1
+ <script lang="ts">
2
+ import { KeyRound } from "@lucide/svelte";
3
+ import { toast } from "svelte-sonner";
4
+ import { Button } from "../components/ui/button/index.js";
5
+ import { Input } from "../components/ui/input/index.js";
6
+ import { Label } from "../components/ui/label/index.js";
7
+ import { m } from "../messages.js";
8
+ import { setPassword } from "../remote/app_user.remote.js";
9
+ import { messageOf } from "../util/messageOf.js";
10
+
11
+ let { onSetSuccess }: { onSetSuccess?: () => void } = $props();
12
+ let isSubmitting = $state(false);
13
+ </script>
14
+
15
+ <div class="panel p-6 md:p-8">
16
+ <h3 class="mb-6 flex items-center gap-2 text-lg font-semibold">
17
+ <KeyRound class="size-5 text-muted-foreground" />
18
+ {m().setPassword}
19
+ </h3>
20
+ <p class="mb-6 text-sm text-muted-foreground">{m().setPasswordIntro}</p>
21
+ <form
22
+ class="space-y-6"
23
+ {...setPassword.enhance(async ({ submit }) => {
24
+ isSubmitting = true;
25
+ try {
26
+ await submit();
27
+ toast.success(m().passwordSet);
28
+ setPassword.fields.newPassword.set("");
29
+ setPassword.fields.confirmPassword.set("");
30
+ onSetSuccess?.();
31
+ } catch (e) {
32
+ toast.error(messageOf(e, m().passwordNotSet));
33
+ } finally {
34
+ isSubmitting = false;
35
+ }
36
+ })}
37
+ >
38
+ <div class="space-y-2">
39
+ <Label for="newPassword" class="text-sm font-medium">{m().newPassword}</Label>
40
+ <Input id="newPassword" type="password" {...setPassword.fields.newPassword.as("text")} placeholder="••••••••" class="bg-muted/30" />
41
+ {#each setPassword.fields.newPassword.issues() as issue (issue.message)}
42
+ <p class="text-sm text-destructive">{issue.message}</p>
43
+ {/each}
44
+ </div>
45
+
46
+ <div class="space-y-2">
47
+ <Label for="confirmPassword" class="text-sm font-medium">{m().confirmNewPasswordShort}</Label>
48
+ <Input id="confirmPassword" type="password" {...setPassword.fields.confirmPassword.as("text")} placeholder="••••••••" class="bg-muted/30" />
49
+ {#each setPassword.fields.confirmPassword.issues() as issue (issue.message)}
50
+ <p class="text-sm text-destructive">{issue.message}</p>
51
+ {/each}
52
+ </div>
53
+
54
+ <Button type="submit" class="cursor-pointer" disabled={isSubmitting}>{isSubmitting ? m().saving : m().setPasswordButton}</Button>
55
+ </form>
56
+ </div>
@@ -0,0 +1,6 @@
1
+ type $$ComponentProps = {
2
+ onSetSuccess?: () => void;
3
+ };
4
+ declare const SetPasswordCard: import("svelte").Component<$$ComponentProps, {}, "">;
5
+ type SetPasswordCard = ReturnType<typeof SetPasswordCard>;
6
+ export default SetPasswordCard;
@@ -0,0 +1,19 @@
1
+ <script lang="ts">
2
+ import * as Avatar from "../components/ui/avatar/index.js";
3
+ import { m } from "../messages.js";
4
+ import type { AppUserClient } from "../types.js";
5
+ import { displayInitials, displayName } from "../util/names.js";
6
+
7
+ let { user }: { user: AppUserClient | null | undefined } = $props();
8
+ </script>
9
+
10
+ <div class="flex items-center gap-6">
11
+ <Avatar.Root class="size-20 rounded-full border-2 border-muted">
12
+ {#if user?.image}<Avatar.Image src={user.image} alt="" />{/if}
13
+ <Avatar.Fallback class="text-2xl">{user ? displayInitials(user) : "?"}</Avatar.Fallback>
14
+ </Avatar.Root>
15
+ <div class="space-y-1">
16
+ <h1 class="text-3xl font-bold tracking-tight">{user ? displayName(user) : m().user}</h1>
17
+ <p class="text-muted-foreground">{user?.email || ""}</p>
18
+ </div>
19
+ </div>
@@ -0,0 +1,7 @@
1
+ import type { AppUserClient } from "../types.js";
2
+ type $$ComponentProps = {
3
+ user: AppUserClient | null | undefined;
4
+ };
5
+ declare const SettingsHeader: import("svelte").Component<$$ComponentProps, {}, "">;
6
+ type SettingsHeader = ReturnType<typeof SettingsHeader>;
7
+ export default SettingsHeader;
@@ -0,0 +1,95 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from "svelte";
3
+ import { ArrowLeft } from "@lucide/svelte";
4
+ import { goto } from "$app/navigation";
5
+ import { page } from "$app/state";
6
+ import * as Tabs from "../components/ui/tabs/index.js";
7
+ import { m } from "../messages.js";
8
+ import type { AppUserClient } from "../types.js";
9
+ import { authUi } from "./context.js";
10
+ import DeletionTab from "./DeletionTab.svelte";
11
+ import InvitationsTab from "./InvitationsTab.svelte";
12
+ import LinksTab from "./LinksTab.svelte";
13
+ import MobileSettingsMenu from "./MobileSettingsMenu.svelte";
14
+ import ProfileManagementTab from "./ProfileManagementTab.svelte";
15
+ import SecurityTab from "./SecurityTab.svelte";
16
+ import SessionsTab from "./SessionsTab.svelte";
17
+ import SettingsHeader from "./SettingsHeader.svelte";
18
+ import SettingsTabsList from "./SettingsTabsList.svelte";
19
+ import { visibleTabs, type SettingsTabDef } from "./tabs.js";
20
+
21
+ /**
22
+ * The settings page (`homeLayoutLoad` gives `user`, `session`, `providers` and `einladen` in the page data): on
23
+ * desktop a tab bar, on mobile a menu and one tab at a time, `?tab=` either way. A Product adds its own tabs with
24
+ * `extraTabs` (their definitions) and `extra` (their content, given the tab id), e.g. the AI assistants of `@vonall/mcp`.
25
+ */
26
+ let { extraTabs = [], extra }: { extraTabs?: SettingsTabDef[]; extra?: Snippet<[string]> } = $props();
27
+ const { appName, locale } = authUi();
28
+
29
+ const user = $derived(page.data.user as AppUserClient);
30
+ const einladen = $derived(Boolean(page.data.einladen));
31
+ // Only the tabs this user sees: ?tab=invitations without the right to invite falls back to the profile
32
+ const tabs = $derived(visibleTabs(einladen, extraTabs));
33
+ const ids = $derived(tabs.map((t) => t.id));
34
+ const isTab = (value: string | null): value is string => value !== null && ids.includes(value);
35
+
36
+ let activeTab = $state("profile");
37
+
38
+ $effect(() => {
39
+ const tab = page.url.searchParams.get("tab");
40
+ activeTab = isTab(tab) ? tab : "profile";
41
+ });
42
+
43
+ const hasTabParam = $derived(isTab(page.url.searchParams.get("tab")));
44
+ const activeTabTitle = $derived(tabs.find((t) => t.id === page.url.searchParams.get("tab"))?.label ?? "");
45
+
46
+ function selectTab(tabName: string) {
47
+ const url = new URL(page.url.href);
48
+ if (tabName) url.searchParams.set("tab", tabName);
49
+ else url.searchParams.delete("tab");
50
+ goto(`${page.url.pathname}${url.search}`); // wherever the Product mounted this page
51
+ }
52
+
53
+ const memberSince = $derived(user.createdAt ? new Date(user.createdAt).toLocaleDateString(locale, { year: "numeric", month: "long" }) : "");
54
+ </script>
55
+
56
+ <svelte:head><title>{m().settingsTitle} · {appName}</title></svelte:head>
57
+
58
+ <!-- One Tabs.Root for both layouts, so the tab contents (and their remote forms) mount exactly once -->
59
+ <Tabs.Root bind:value={activeTab} class="w-full">
60
+ <!-- Desktop -->
61
+ <div class="hidden w-full space-y-6 md:block">
62
+ <SettingsHeader {user} />
63
+ <SettingsTabsList {tabs} />
64
+ </div>
65
+
66
+ <!-- Mobile: a menu, then one tab at a time -->
67
+ <div class="w-full space-y-4 md:hidden">
68
+ {#if !hasTabParam}
69
+ <MobileSettingsMenu {user} {memberSince} {tabs} onSelectTab={selectTab} />
70
+ {:else}
71
+ <div class="panel flex items-center gap-3 p-3">
72
+ <button onclick={() => selectTab("")} class="rounded-full p-2 text-muted-foreground hover:bg-accent" aria-label={m().backToOverview}>
73
+ <ArrowLeft size={18} />
74
+ </button>
75
+ <div>
76
+ <span class="section-kicker">{m().settingsTitle}</span>
77
+ <h2 class="text-base font-semibold">{activeTabTitle}</h2>
78
+ </div>
79
+ </div>
80
+ {/if}
81
+ </div>
82
+
83
+ <div class={hasTabParam ? "mt-4 block w-full" : "mt-4 hidden w-full md:block"}>
84
+ <ProfileManagementTab {user} />
85
+ <SecurityTab />
86
+ <!-- Only while open: listing the sessions needs a login from the last day, which only this tab should ask for -->
87
+ {#if activeTab === "sessions"}<SessionsTab />{/if}
88
+ <LinksTab />
89
+ {#if einladen}<InvitationsTab />{/if}
90
+ {#each extraTabs as tab (tab.id)}
91
+ <Tabs.Content value={tab.id} class="panel mt-6 p-6 md:p-8">{@render extra?.(tab.id)}</Tabs.Content>
92
+ {/each}
93
+ <DeletionTab agents={extraTabs.some((t) => t.id === "agents")} />
94
+ </div>
95
+ </Tabs.Root>
@@ -0,0 +1,9 @@
1
+ import type { Snippet } from "svelte";
2
+ import { type SettingsTabDef } from "./tabs.js";
3
+ type $$ComponentProps = {
4
+ extraTabs?: SettingsTabDef[];
5
+ extra?: Snippet<[string]>;
6
+ };
7
+ declare const SettingsPage: import("svelte").Component<$$ComponentProps, {}, "">;
8
+ type SettingsPage = ReturnType<typeof SettingsPage>;
9
+ export default SettingsPage;