@lukoweb/apitogo-module-user-panel 0.1.88 → 0.1.90

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lukoweb/apitogo-module-user-panel",
3
- "version": "0.1.88",
3
+ "version": "0.1.90",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./src/index.tsx"
@@ -14,7 +14,7 @@
14
14
  "devDependencies": {
15
15
  "@types/react": "19.2.14",
16
16
  "@types/react-dom": "19.2.3",
17
- "@lukoweb/apitogo": "0.1.88",
17
+ "@lukoweb/apitogo": "0.1.90",
18
18
  "react": "19.2.4",
19
19
  "react-dom": "19.2.4",
20
20
  "react-router": "7.13.1",
@@ -0,0 +1,42 @@
1
+ /** Default account area paths (match dev-portal billing plugin). */
2
+ export const DEFAULT_ACCOUNT_BASE_PATH = "/account";
3
+
4
+ export type AccountNavPaths = {
5
+ overview: string;
6
+ apiKeys: string;
7
+ usage: string;
8
+ billing: string;
9
+ settings: string;
10
+ docs: string;
11
+ };
12
+
13
+ export function buildAccountNavPaths(
14
+ basePath = DEFAULT_ACCOUNT_BASE_PATH,
15
+ docsPath = "/introduction",
16
+ ): AccountNavPaths {
17
+ const base = basePath.replace(/\/+$/, "") || DEFAULT_ACCOUNT_BASE_PATH;
18
+
19
+ return {
20
+ overview: `${base}/dashboard`,
21
+ apiKeys: `${base}/credentials`,
22
+ usage: `${base}/usage`,
23
+ billing: `${base}/subscriptions`,
24
+ settings: `${base}/profile`,
25
+ docs: docsPath,
26
+ };
27
+ }
28
+
29
+ export function toAccountChildPath(basePath: string, fullPath: string): string {
30
+ const base = basePath.replace(/\/+$/, "");
31
+ const normalized = fullPath.replace(/\/+$/, "") || fullPath;
32
+
33
+ if (normalized === base) {
34
+ return "";
35
+ }
36
+
37
+ if (normalized.startsWith(`${base}/`)) {
38
+ return normalized.slice(base.length + 1);
39
+ }
40
+
41
+ return normalized.replace(/^\/+/, "");
42
+ }
@@ -0,0 +1,119 @@
1
+ import { cn } from "@lukoweb/apitogo";
2
+ import { useAuth } from "@lukoweb/apitogo/hooks";
3
+ import {
4
+ BookOpenIcon,
5
+ CreditCardIcon,
6
+ KeyRoundIcon,
7
+ LayoutGridIcon,
8
+ LogOutIcon,
9
+ SettingsIcon,
10
+ BarChart3Icon,
11
+ } from "@lukoweb/apitogo/icons";
12
+ import { Link, NavLink, Outlet } from "@lukoweb/apitogo/router";
13
+ import type { ReactNode } from "react";
14
+ import {
15
+ buildAccountNavPaths,
16
+ DEFAULT_ACCOUNT_BASE_PATH,
17
+ } from "../account-paths.js";
18
+
19
+ type AccountNavItem = {
20
+ label: string;
21
+ to: string;
22
+ icon: typeof LayoutGridIcon;
23
+ };
24
+
25
+ export type AccountLayoutProps = {
26
+ basePath?: string;
27
+ docsPath?: string;
28
+ children?: ReactNode;
29
+ /** When true, show API keys / Usage / Billing (billing plugin routes). */
30
+ includeBillingNav?: boolean;
31
+ };
32
+
33
+ export function AccountLayout({
34
+ basePath = DEFAULT_ACCOUNT_BASE_PATH,
35
+ docsPath = "/introduction",
36
+ children,
37
+ includeBillingNav = false,
38
+ }: AccountLayoutProps) {
39
+ const auth = useAuth();
40
+ const paths = buildAccountNavPaths(basePath, docsPath);
41
+
42
+ const navItems: AccountNavItem[] = [
43
+ { label: "Overview", to: paths.overview, icon: LayoutGridIcon },
44
+ ...(includeBillingNav
45
+ ? [
46
+ { label: "API keys", to: paths.apiKeys, icon: KeyRoundIcon },
47
+ { label: "Usage", to: paths.usage, icon: BarChart3Icon },
48
+ { label: "Billing", to: paths.billing, icon: CreditCardIcon },
49
+ ]
50
+ : []),
51
+ { label: "Settings", to: paths.settings, icon: SettingsIcon },
52
+ ];
53
+
54
+ const renderNavLink = (item: AccountNavItem, compact = false) => (
55
+ <NavLink
56
+ key={item.to}
57
+ to={item.to}
58
+ end={item.to === paths.overview}
59
+ className={({ isActive }) =>
60
+ cn(
61
+ compact
62
+ ? "inline-flex shrink-0 items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors"
63
+ : "flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors",
64
+ isActive
65
+ ? "bg-primary/14 font-semibold text-primary"
66
+ : "text-muted-foreground hover:bg-muted/80 hover:text-foreground",
67
+ )
68
+ }
69
+ >
70
+ <item.icon
71
+ size={compact ? 18 : 20}
72
+ strokeWidth={1.8}
73
+ className="shrink-0 opacity-80"
74
+ />
75
+ {item.label}
76
+ </NavLink>
77
+ );
78
+
79
+ return (
80
+ <div className="flex min-h-[calc(100dvh-var(--header-height))] flex-col bg-muted md:flex-row">
81
+ <nav
82
+ aria-label="Account"
83
+ className="flex gap-1 overflow-x-auto border-b border-border bg-card px-3 py-2 md:hidden"
84
+ >
85
+ {navItems.map((item) => renderNavLink(item, true))}
86
+ </nav>
87
+
88
+ <aside className="hidden w-[236px] shrink-0 flex-col justify-between border-r border-border bg-card px-3 py-4 md:flex">
89
+ <nav className="flex flex-col gap-1">
90
+ {navItems.map((item) => renderNavLink(item))}
91
+ </nav>
92
+
93
+ <div className="flex flex-col gap-1 border-t border-border pt-3">
94
+ <Link
95
+ to={paths.docs}
96
+ className="flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium text-muted-foreground transition-colors hover:bg-muted/80 hover:text-foreground"
97
+ >
98
+ <BookOpenIcon size={20} strokeWidth={1.8} className="shrink-0" />
99
+ Documentation
100
+ </Link>
101
+ {auth.isAuthEnabled ? (
102
+ <button
103
+ type="button"
104
+ className="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left text-sm font-medium text-muted-foreground transition-colors hover:bg-muted/80 hover:text-foreground"
105
+ onClick={() => {
106
+ void auth.logout();
107
+ }}
108
+ >
109
+ <LogOutIcon size={20} strokeWidth={1.8} className="shrink-0" />
110
+ Log out
111
+ </button>
112
+ ) : null}
113
+ </div>
114
+ </aside>
115
+
116
+ <main className="min-w-0 flex-1">{children ?? <Outlet />}</main>
117
+ </div>
118
+ );
119
+ }
@@ -0,0 +1,93 @@
1
+ import { useAuth, useZudoku } from "@lukoweb/apitogo/hooks";
2
+ import {
3
+ useMutation,
4
+ useQuery,
5
+ useQueryClient,
6
+ } from "@lukoweb/apitogo/react-query";
7
+ import {
8
+ changeAccountPassword,
9
+ deleteAccount,
10
+ fetchAccountMe,
11
+ resolveDevPortalApiBaseUrl,
12
+ updateAccountProfile,
13
+ } from "../utils/dev-portal-api.js";
14
+
15
+ export function useAccountMe() {
16
+ const auth = useAuth();
17
+ const apiBaseUrl = resolveDevPortalApiBaseUrl(
18
+ auth.providerData as { type: "dev-portal"; apiBaseUrl: string } | null,
19
+ );
20
+
21
+ return useQuery({
22
+ queryKey: ["account-me", apiBaseUrl],
23
+ enabled: auth.isAuthenticated && Boolean(apiBaseUrl),
24
+ queryFn: () => fetchAccountMe(apiBaseUrl!),
25
+ });
26
+ }
27
+
28
+ export function useUpdateAccountProfile() {
29
+ const auth = useAuth();
30
+ const { authentication } = useZudoku();
31
+ const queryClient = useQueryClient();
32
+ const apiBaseUrl = resolveDevPortalApiBaseUrl(
33
+ auth.providerData as { type: "dev-portal"; apiBaseUrl: string } | null,
34
+ );
35
+
36
+ return useMutation({
37
+ mutationFn: (displayName: string) => {
38
+ if (!apiBaseUrl) {
39
+ throw new Error("Dev portal API is not configured.");
40
+ }
41
+
42
+ return updateAccountProfile(apiBaseUrl, displayName);
43
+ },
44
+ onSuccess: async () => {
45
+ await queryClient.invalidateQueries({ queryKey: ["account-me"] });
46
+ await queryClient.invalidateQueries({ queryKey: ["dev-portal-me"] });
47
+ await authentication?.refreshUserProfile?.();
48
+ },
49
+ });
50
+ }
51
+
52
+ export function useChangeAccountPassword() {
53
+ const auth = useAuth();
54
+ const apiBaseUrl = resolveDevPortalApiBaseUrl(
55
+ auth.providerData as { type: "dev-portal"; apiBaseUrl: string } | null,
56
+ );
57
+
58
+ return useMutation({
59
+ mutationFn: ({
60
+ currentPassword,
61
+ newPassword,
62
+ }: {
63
+ currentPassword: string;
64
+ newPassword: string;
65
+ }) => {
66
+ if (!apiBaseUrl) {
67
+ throw new Error("Dev portal API is not configured.");
68
+ }
69
+
70
+ return changeAccountPassword(apiBaseUrl, currentPassword, newPassword);
71
+ },
72
+ });
73
+ }
74
+
75
+ export function useDeleteAccount() {
76
+ const auth = useAuth();
77
+ const apiBaseUrl = resolveDevPortalApiBaseUrl(
78
+ auth.providerData as { type: "dev-portal"; apiBaseUrl: string } | null,
79
+ );
80
+
81
+ return useMutation({
82
+ mutationFn: (password?: string) => {
83
+ if (!apiBaseUrl) {
84
+ throw new Error("Dev portal API is not configured.");
85
+ }
86
+
87
+ return deleteAccount(apiBaseUrl, password);
88
+ },
89
+ onSuccess: async () => {
90
+ await auth.logout();
91
+ },
92
+ });
93
+ }
package/src/index.tsx CHANGED
@@ -1,5 +1,14 @@
1
1
  export { userPanelModule } from "./userPanelModule.js";
2
2
  export type { UserPanelModuleOptions } from "./userPanelModule.js";
3
+ export { AccountLayout } from "./components/AccountLayout.js";
4
+ export type { AccountLayoutProps } from "./components/AccountLayout.js";
5
+ export {
6
+ buildAccountNavPaths,
7
+ DEFAULT_ACCOUNT_BASE_PATH,
8
+ toAccountChildPath,
9
+ } from "./account-paths.js";
10
+ export type { AccountNavPaths } from "./account-paths.js";
3
11
  export { DefaultDashboardPage } from "./pages/DefaultDashboardPage.js";
12
+ export { SettingsPage } from "./pages/SettingsPage.js";
4
13
  export { DefaultProfilePage } from "./pages/DefaultProfilePage.js";
5
14
  export { DefaultPlansPage } from "./pages/DefaultPlansPage.js";
@@ -1,84 +1,123 @@
1
1
  import type { ResolvedDashboardContent } from "@lukoweb/apitogo";
2
2
  import { Head, Link } from "@lukoweb/apitogo/components";
3
3
  import { useAuth } from "@lukoweb/apitogo/hooks";
4
+ import { ArrowRightIcon, LayoutDashboardIcon } from "@lukoweb/apitogo/icons";
4
5
  import { Button } from "@lukoweb/apitogo/ui/Button.js";
5
- import {
6
- ArrowRightIcon,
7
- BookOpenIcon,
8
- CodeIcon,
9
- LayoutDashboardIcon,
10
- } from "lucide-react";
11
6
 
12
7
  export type DefaultDashboardPageProps = {
13
8
  content: ResolvedDashboardContent;
14
9
  };
15
10
 
11
+ function StatCard({
12
+ label,
13
+ value,
14
+ sub,
15
+ }: {
16
+ label: string;
17
+ value: string;
18
+ sub: string;
19
+ }) {
20
+ return (
21
+ <div className="rounded-xl border border-border bg-card p-5">
22
+ <div className="text-xs font-medium uppercase tracking-[0.05em] text-muted-foreground">
23
+ {label}
24
+ </div>
25
+ <div className="mt-1.5 text-[1.7rem] font-bold tracking-[-0.02em] text-foreground">
26
+ {value}
27
+ </div>
28
+ <div className="mt-1 text-xs text-muted-foreground">{sub}</div>
29
+ </div>
30
+ );
31
+ }
32
+
16
33
  export const DefaultDashboardPage = ({
17
34
  content,
18
35
  }: DefaultDashboardPageProps) => {
19
36
  const auth = useAuth();
20
- const title = content.title ?? "Dashboard";
21
- const subtitle = content.subtitle;
37
+ const title = content.title ?? "Overview";
22
38
  const description =
23
39
  content.description ??
24
40
  "Overview of your account activity, documentation shortcuts, and next steps.";
25
- const quickLinks = content.quickLinks ?? [];
26
41
 
27
42
  const greeting = auth.isAuthenticated
28
43
  ? auth.profile?.name || auth.profile?.email
29
44
  : undefined;
30
45
 
31
46
  return (
32
- <div className="mx-auto max-w-screen-xl px-4 pb-16 pt-(--padding-content-top) lg:px-8">
47
+ <div className="px-8 py-7">
33
48
  <Head>
34
49
  <title>{title}</title>
35
50
  </Head>
36
51
 
37
- <div className="mb-8 flex items-start gap-4">
52
+ <div className="mb-6 flex items-start gap-4">
38
53
  <div className="flex size-12 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary">
39
54
  <LayoutDashboardIcon size={24} />
40
55
  </div>
41
56
  <div className="space-y-1">
42
- {subtitle ? (
43
- <p className="text-sm font-medium tracking-wide text-muted-foreground uppercase">
44
- {subtitle}
45
- </p>
46
- ) : null}
47
- <h1 className="text-3xl font-semibold tracking-tight">
57
+ <h1 className="text-[1.6rem] font-bold tracking-[-0.01em]">
48
58
  {greeting ? `Welcome back, ${greeting}` : title}
49
59
  </h1>
50
60
  <p className="max-w-2xl text-muted-foreground">{description}</p>
51
61
  </div>
52
62
  </div>
53
63
 
54
- {quickLinks.length > 0 ? (
55
- <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
56
- {quickLinks.map((link) => (
57
- <article
58
- key={link.href}
59
- className="rounded-xl border bg-card/60 p-6 shadow-xs"
60
- >
61
- <div className="mb-4 flex size-10 items-center justify-center rounded-lg bg-primary/10 text-primary">
62
- {link.href.includes("api") ? (
63
- <CodeIcon size={20} />
64
- ) : (
65
- <BookOpenIcon size={20} />
66
- )}
67
- </div>
68
- <h2 className="text-lg font-medium">{link.label}</h2>
69
- <Button asChild variant="link" className="mt-2 h-auto p-0">
70
- <Link to={link.href}>
71
- Open
72
- <ArrowRightIcon className="ms-1" size={16} />
64
+ <div className="mb-6 grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
65
+ <StatCard
66
+ label="Requests (30d)"
67
+ value="—"
68
+ sub="Connect billing for usage metrics"
69
+ />
70
+ <StatCard label="Active keys" value="—" sub="Enable API credentials" />
71
+ <StatCard label="Plan" value="—" sub="Add plans in your manifest" />
72
+ <StatCard label="Uptime" value="—" sub="Telemetry coming soon" />
73
+ </div>
74
+
75
+ <div className="mb-5 grid grid-cols-1 gap-5 lg:grid-cols-2">
76
+ <div className="rounded-xl border border-border bg-card p-6">
77
+ <h2 className="mb-4 text-lg font-semibold">Requests, last 14 days</h2>
78
+ <div className="flex min-h-[150px] items-center justify-center rounded-lg border border-dashed border-border bg-muted/30 px-4 text-center text-sm text-muted-foreground">
79
+ Usage charts appear when the dev-portal billing plugin is
80
+ configured.
81
+ </div>
82
+ </div>
83
+ <div className="rounded-xl border border-border bg-card p-6">
84
+ <h2 className="mb-4 text-lg font-semibold">Quick links</h2>
85
+ <div className="flex flex-col gap-3">
86
+ {(content.quickLinks ?? []).length > 0 ? (
87
+ content.quickLinks?.map((link) => (
88
+ <Button
89
+ key={link.href}
90
+ asChild
91
+ variant="outline"
92
+ className="justify-between"
93
+ >
94
+ <Link to={link.href}>
95
+ {link.label}
96
+ <ArrowRightIcon size={16} />
97
+ </Link>
98
+ </Button>
99
+ ))
100
+ ) : (
101
+ <Button asChild variant="outline" className="justify-between">
102
+ <Link to="/introduction">
103
+ Documentation
104
+ <ArrowRightIcon size={16} />
73
105
  </Link>
74
106
  </Button>
75
- </article>
76
- ))}
107
+ )}
108
+ </div>
77
109
  </div>
78
- ) : null}
110
+ </div>
111
+
112
+ <div className="rounded-xl border border-border bg-card p-6">
113
+ <h2 className="mb-4 text-lg font-semibold">Recent requests</h2>
114
+ <p className="text-sm text-muted-foreground">
115
+ Request logs will appear when usage analytics is available.
116
+ </p>
117
+ </div>
79
118
 
80
119
  {!auth.isAuthenticated && auth.isAuthEnabled ? (
81
- <p className="mt-8 text-sm text-muted-foreground">
120
+ <p className="mt-6 text-sm text-muted-foreground">
82
121
  Sign in to see personalized account information on your dashboard.
83
122
  </p>
84
123
  ) : null}
@@ -0,0 +1,279 @@
1
+ import type { ResolvedProfileContent } from "@lukoweb/apitogo";
2
+ import { Head, Link } from "@lukoweb/apitogo/components";
3
+ import { useAuth } from "@lukoweb/apitogo/hooks";
4
+ import { Button } from "@lukoweb/apitogo/ui/Button.js";
5
+ import {
6
+ Card,
7
+ CardContent,
8
+ CardHeader,
9
+ CardTitle,
10
+ } from "@lukoweb/apitogo/ui/Card.js";
11
+ import { Input } from "@lukoweb/apitogo/ui/Input.js";
12
+ import { Label } from "@lukoweb/apitogo/ui/Label.js";
13
+ import { useEffect, useState } from "react";
14
+ import {
15
+ useAccountMe,
16
+ useChangeAccountPassword,
17
+ useDeleteAccount,
18
+ useUpdateAccountProfile,
19
+ } from "../hooks/useAccountSettings.js";
20
+
21
+ export type SettingsPageProps = {
22
+ content: ResolvedProfileContent;
23
+ };
24
+
25
+ export const SettingsPage = ({ content }: SettingsPageProps) => {
26
+ const auth = useAuth();
27
+ const { data: account, isLoading } = useAccountMe();
28
+ const updateProfile = useUpdateAccountProfile();
29
+ const changePassword = useChangeAccountPassword();
30
+ const deleteAccountMutation = useDeleteAccount();
31
+
32
+ const [displayName, setDisplayName] = useState("");
33
+ const [currentPassword, setCurrentPassword] = useState("");
34
+ const [newPassword, setNewPassword] = useState("");
35
+ const [deletePassword, setDeletePassword] = useState("");
36
+ const [deleteConfirm, setDeleteConfirm] = useState("");
37
+ const [profileMessage, setProfileMessage] = useState<string | null>(null);
38
+ const [passwordMessage, setPasswordMessage] = useState<string | null>(null);
39
+ const [deleteMessage, setDeleteMessage] = useState<string | null>(null);
40
+
41
+ const title = content.title ?? "Settings";
42
+ const hasNativePassword = account?.hasNativePassword === true;
43
+ const email = account?.email ?? auth.profile?.email ?? "";
44
+
45
+ useEffect(() => {
46
+ if (account?.displayName !== undefined) {
47
+ setDisplayName(account.displayName ?? "");
48
+ } else if (auth.profile?.name) {
49
+ setDisplayName(auth.profile.name);
50
+ }
51
+ }, [account?.displayName, auth.profile?.name]);
52
+
53
+ if (!auth.isAuthenticated) {
54
+ return (
55
+ <div className="mx-auto max-w-[760px] px-8 py-7">
56
+ <Head>
57
+ <title>{title}</title>
58
+ </Head>
59
+ <h1 className="mb-2 text-[1.6rem] font-bold tracking-[-0.01em]">
60
+ {title}
61
+ </h1>
62
+ <p className="text-muted-foreground">
63
+ {auth.isAuthEnabled
64
+ ? "Sign in to manage your account settings."
65
+ : "Configure authentication to enable account settings."}
66
+ </p>
67
+ {auth.isAuthEnabled ? (
68
+ <Button asChild className="mt-4">
69
+ <Link to="/signin">Sign in</Link>
70
+ </Button>
71
+ ) : null}
72
+ </div>
73
+ );
74
+ }
75
+
76
+ return (
77
+ <div className="mx-auto max-w-[760px] px-8 py-7">
78
+ <Head>
79
+ <title>{title}</title>
80
+ </Head>
81
+
82
+ <h1 className="mb-6 text-[1.6rem] font-bold tracking-[-0.01em]">
83
+ {title}
84
+ </h1>
85
+
86
+ <div className="grid gap-5">
87
+ <Card>
88
+ <CardHeader>
89
+ <CardTitle>Profile</CardTitle>
90
+ </CardHeader>
91
+ <CardContent className="space-y-4">
92
+ <div className="grid gap-4 sm:grid-cols-2">
93
+ <div className="space-y-2">
94
+ <Label htmlFor="settings-display-name">Full name</Label>
95
+ <Input
96
+ id="settings-display-name"
97
+ value={displayName}
98
+ onChange={(event) => setDisplayName(event.target.value)}
99
+ disabled={isLoading || updateProfile.isPending}
100
+ />
101
+ </div>
102
+ <div className="space-y-2">
103
+ <Label htmlFor="settings-email">Email</Label>
104
+ <Input
105
+ id="settings-email"
106
+ type="email"
107
+ value={email}
108
+ readOnly
109
+ disabled
110
+ />
111
+ </div>
112
+ </div>
113
+ {profileMessage ? (
114
+ <p className="text-sm text-muted-foreground">{profileMessage}</p>
115
+ ) : null}
116
+ <Button
117
+ size="sm"
118
+ disabled={isLoading || updateProfile.isPending}
119
+ onClick={() => {
120
+ setProfileMessage(null);
121
+ updateProfile.mutate(displayName, {
122
+ onSuccess: () => setProfileMessage("Profile saved."),
123
+ onError: (error) =>
124
+ setProfileMessage(
125
+ error instanceof Error
126
+ ? error.message
127
+ : "Unable to save profile.",
128
+ ),
129
+ });
130
+ }}
131
+ >
132
+ Save changes
133
+ </Button>
134
+ </CardContent>
135
+ </Card>
136
+
137
+ {hasNativePassword ? (
138
+ <Card>
139
+ <CardHeader>
140
+ <CardTitle>Password</CardTitle>
141
+ </CardHeader>
142
+ <CardContent className="space-y-4">
143
+ <div className="grid gap-4 sm:grid-cols-2">
144
+ <div className="space-y-2">
145
+ <Label htmlFor="settings-current-password">
146
+ Current password
147
+ </Label>
148
+ <Input
149
+ id="settings-current-password"
150
+ type="password"
151
+ value={currentPassword}
152
+ onChange={(event) => setCurrentPassword(event.target.value)}
153
+ autoComplete="current-password"
154
+ />
155
+ </div>
156
+ <div className="space-y-2">
157
+ <Label htmlFor="settings-new-password">New password</Label>
158
+ <Input
159
+ id="settings-new-password"
160
+ type="password"
161
+ value={newPassword}
162
+ onChange={(event) => setNewPassword(event.target.value)}
163
+ autoComplete="new-password"
164
+ />
165
+ </div>
166
+ </div>
167
+ {passwordMessage ? (
168
+ <p className="text-sm text-muted-foreground">
169
+ {passwordMessage}
170
+ </p>
171
+ ) : null}
172
+ <Button
173
+ variant="outline"
174
+ size="sm"
175
+ disabled={changePassword.isPending}
176
+ onClick={() => {
177
+ setPasswordMessage(null);
178
+ changePassword.mutate(
179
+ { currentPassword, newPassword },
180
+ {
181
+ onSuccess: () => {
182
+ setCurrentPassword("");
183
+ setNewPassword("");
184
+ setPasswordMessage("Password updated.");
185
+ },
186
+ onError: (error) =>
187
+ setPasswordMessage(
188
+ error instanceof Error
189
+ ? error.message
190
+ : "Unable to update password.",
191
+ ),
192
+ },
193
+ );
194
+ }}
195
+ >
196
+ Update password
197
+ </Button>
198
+ </CardContent>
199
+ </Card>
200
+ ) : null}
201
+
202
+ <Card className="border-destructive/40">
203
+ <CardHeader>
204
+ <CardTitle className="text-destructive">Danger zone</CardTitle>
205
+ </CardHeader>
206
+ <CardContent className="space-y-4">
207
+ <p className="text-sm text-muted-foreground">
208
+ Deleting your account removes all keys, usage history, and billing
209
+ data. This cannot be undone.
210
+ </p>
211
+ {hasNativePassword ? (
212
+ <div className="max-w-sm space-y-2">
213
+ <Label htmlFor="settings-delete-password">
214
+ Confirm with password
215
+ </Label>
216
+ <Input
217
+ id="settings-delete-password"
218
+ type="password"
219
+ value={deletePassword}
220
+ onChange={(event) => setDeletePassword(event.target.value)}
221
+ autoComplete="current-password"
222
+ />
223
+ </div>
224
+ ) : (
225
+ <div className="max-w-sm space-y-2">
226
+ <Label htmlFor="settings-delete-confirm">
227
+ Type DELETE to confirm
228
+ </Label>
229
+ <Input
230
+ id="settings-delete-confirm"
231
+ value={deleteConfirm}
232
+ onChange={(event) => setDeleteConfirm(event.target.value)}
233
+ autoComplete="off"
234
+ />
235
+ </div>
236
+ )}
237
+ {deleteMessage ? (
238
+ <p className="text-sm text-muted-foreground">{deleteMessage}</p>
239
+ ) : null}
240
+ <Button
241
+ variant="outline"
242
+ size="sm"
243
+ className="border-destructive text-destructive hover:bg-destructive/10"
244
+ disabled={
245
+ deleteAccountMutation.isPending ||
246
+ (hasNativePassword
247
+ ? !deletePassword
248
+ : deleteConfirm !== "DELETE")
249
+ }
250
+ onClick={() => {
251
+ if (
252
+ !window.confirm(
253
+ "Delete your account permanently? This cannot be undone.",
254
+ )
255
+ ) {
256
+ return;
257
+ }
258
+ setDeleteMessage(null);
259
+ deleteAccountMutation.mutate(
260
+ hasNativePassword ? deletePassword : undefined,
261
+ {
262
+ onError: (error) =>
263
+ setDeleteMessage(
264
+ error instanceof Error
265
+ ? error.message
266
+ : "Unable to delete account.",
267
+ ),
268
+ },
269
+ );
270
+ }}
271
+ >
272
+ Delete account
273
+ </Button>
274
+ </CardContent>
275
+ </Card>
276
+ </div>
277
+ </div>
278
+ );
279
+ };
@@ -4,16 +4,22 @@ import type {
4
4
  ResolvedUserManagementModule,
5
5
  ResolvedUserPanelModule,
6
6
  } from "@lukoweb/apitogo";
7
+ import {
8
+ CreditCardIcon,
9
+ LayoutDashboardIcon,
10
+ SettingsIcon,
11
+ } from "@lukoweb/apitogo/icons";
7
12
  import type {
8
13
  ProfileMenuPlugin,
9
14
  ApitogoPlugin,
10
15
  } from "@lukoweb/apitogo/plugins";
11
- import { CreditCardIcon, LayoutDashboardIcon, UserIcon } from "lucide-react";
12
16
  import type { ComponentType, ReactNode } from "react";
13
17
  import type { RouteObject } from "react-router";
18
+ import { toAccountChildPath } from "./account-paths.js";
19
+ import { AccountLayout } from "./components/AccountLayout.js";
14
20
  import { DefaultDashboardPage } from "./pages/DefaultDashboardPage.js";
15
21
  import { DefaultPlansPage } from "./pages/DefaultPlansPage.js";
16
- import { DefaultProfilePage } from "./pages/DefaultProfilePage.js";
22
+ import { SettingsPage } from "./pages/SettingsPage.js";
17
23
  import { renderModuleElement } from "./renderModuleElement.js";
18
24
 
19
25
  type DashboardModuleOptions = ResolvedDashboardModule & {
@@ -56,15 +62,17 @@ export const userPanelModule = (
56
62
  const profileMenuItems: ReturnType<ProfileMenuPlugin["getProfileMenuItems"]> =
57
63
  [];
58
64
  const protectAccountRoutes = options.userManagement.enabled;
65
+ const basePath = options.basePath ?? "/account";
66
+ const accountChildren: RouteObject[] = [];
59
67
 
60
68
  if (options.dashboard.enabled) {
61
- routes.push({
62
- path: options.dashboard.path,
69
+ accountChildren.push({
70
+ path: toAccountChildPath(basePath, options.dashboard.path),
63
71
  element: renderModuleElement(
64
72
  options.dashboard.component,
65
73
  <DefaultDashboardPage content={options.dashboard.content} />,
66
74
  ),
67
- handle: { layout: "default" },
75
+ handle: { layout: "none" },
68
76
  });
69
77
  protectAccountRoute(
70
78
  protectedRoutes,
@@ -82,32 +90,32 @@ export const userPanelModule = (
82
90
 
83
91
  if (options.userManagement.enabled) {
84
92
  const profilePath = options.userManagement.routes.profile;
85
- routes.push({
86
- path: profilePath,
93
+ accountChildren.push({
94
+ path: toAccountChildPath(basePath, profilePath),
87
95
  element: renderModuleElement(
88
96
  options.userManagement.component,
89
- <DefaultProfilePage content={options.userManagement.content} />,
97
+ <SettingsPage content={options.userManagement.content} />,
90
98
  ),
91
- handle: { layout: "default" },
99
+ handle: { layout: "none" },
92
100
  });
93
101
  protectAccountRoute(protectedRoutes, protectAccountRoutes, profilePath);
94
102
  profileMenuItems.push({
95
- label: "Profile",
103
+ label: "Settings",
96
104
  path: profilePath,
97
105
  category: "middle",
98
- icon: UserIcon,
106
+ icon: SettingsIcon,
99
107
  weight: 20,
100
108
  });
101
109
  }
102
110
 
103
111
  if (options.plans.enabled) {
104
- routes.push({
105
- path: options.plans.path,
112
+ accountChildren.push({
113
+ path: toAccountChildPath(basePath, options.plans.path),
106
114
  element: renderModuleElement(
107
115
  options.plans.component,
108
116
  <DefaultPlansPage content={options.plans.content} />,
109
117
  ),
110
- handle: { layout: "default" },
118
+ handle: { layout: "none" },
111
119
  });
112
120
  protectAccountRoute(
113
121
  protectedRoutes,
@@ -131,6 +139,15 @@ export const userPanelModule = (
131
139
  );
132
140
  }
133
141
 
142
+ if (accountChildren.length > 0) {
143
+ routes.push({
144
+ path: basePath,
145
+ element: <AccountLayout basePath={basePath} />,
146
+ handle: { layout: "default" },
147
+ children: accountChildren,
148
+ });
149
+ }
150
+
134
151
  return {
135
152
  getRoutes: () => routes,
136
153
  getProtectedRoutes: () => protectedRoutes,
@@ -0,0 +1,125 @@
1
+ type DevPortalProviderData = {
2
+ type: "dev-portal";
3
+ apiBaseUrl: string;
4
+ };
5
+
6
+ export function resolveDevPortalApiBaseUrl(
7
+ providerData: DevPortalProviderData | null | undefined,
8
+ ): string | undefined {
9
+ if (providerData?.type === "dev-portal" && providerData.apiBaseUrl) {
10
+ return providerData.apiBaseUrl.replace(/\/+$/, "");
11
+ }
12
+
13
+ const envUrl =
14
+ (typeof import.meta.env.VITE_APITOGO_AUTHENTICATION_API_BASE_URL ===
15
+ "string"
16
+ ? import.meta.env.VITE_APITOGO_AUTHENTICATION_API_BASE_URL.trim()
17
+ : "") ||
18
+ (typeof import.meta.env.VITE_APITOGO_DEV_PORTAL_API_URL === "string"
19
+ ? import.meta.env.VITE_APITOGO_DEV_PORTAL_API_URL.trim()
20
+ : "");
21
+
22
+ return envUrl ? envUrl.replace(/\/+$/, "") : undefined;
23
+ }
24
+
25
+ export type AccountMeResponse = {
26
+ userId: string;
27
+ displayName: string | null;
28
+ email?: string | null;
29
+ emailVerified?: boolean;
30
+ hasNativePassword?: boolean;
31
+ };
32
+
33
+ async function readErrorMessage(response: Response, fallback: string) {
34
+ try {
35
+ const body = (await response.json()) as {
36
+ detail?: string;
37
+ message?: string;
38
+ };
39
+ return body.detail ?? body.message ?? fallback;
40
+ } catch {
41
+ return fallback;
42
+ }
43
+ }
44
+
45
+ export async function fetchAccountMe(
46
+ apiBaseUrl: string,
47
+ ): Promise<AccountMeResponse> {
48
+ const response = await fetch(`${apiBaseUrl}/api/v1/auth/me`, {
49
+ method: "GET",
50
+ headers: { Accept: "application/json" },
51
+ credentials: "include",
52
+ });
53
+
54
+ if (!response.ok) {
55
+ throw new Error("Failed to load account details.");
56
+ }
57
+
58
+ return (await response.json()) as AccountMeResponse;
59
+ }
60
+
61
+ export async function updateAccountProfile(
62
+ apiBaseUrl: string,
63
+ displayName: string,
64
+ ): Promise<AccountMeResponse> {
65
+ const response = await fetch(`${apiBaseUrl}/api/v1/account`, {
66
+ method: "PATCH",
67
+ headers: {
68
+ Accept: "application/json",
69
+ "Content-Type": "application/json",
70
+ },
71
+ credentials: "include",
72
+ body: JSON.stringify({ displayName }),
73
+ });
74
+
75
+ if (!response.ok) {
76
+ throw new Error(
77
+ await readErrorMessage(response, "Unable to save profile."),
78
+ );
79
+ }
80
+
81
+ return (await response.json()) as AccountMeResponse;
82
+ }
83
+
84
+ export async function changeAccountPassword(
85
+ apiBaseUrl: string,
86
+ currentPassword: string,
87
+ newPassword: string,
88
+ ): Promise<void> {
89
+ const response = await fetch(`${apiBaseUrl}/api/v1/account/change-password`, {
90
+ method: "POST",
91
+ headers: {
92
+ Accept: "application/json",
93
+ "Content-Type": "application/json",
94
+ },
95
+ credentials: "include",
96
+ body: JSON.stringify({ currentPassword, newPassword }),
97
+ });
98
+
99
+ if (!response.ok) {
100
+ throw new Error(
101
+ await readErrorMessage(response, "Unable to update password."),
102
+ );
103
+ }
104
+ }
105
+
106
+ export async function deleteAccount(
107
+ apiBaseUrl: string,
108
+ password?: string,
109
+ ): Promise<void> {
110
+ const response = await fetch(`${apiBaseUrl}/api/v1/account`, {
111
+ method: "DELETE",
112
+ headers: {
113
+ Accept: "application/json",
114
+ "Content-Type": "application/json",
115
+ },
116
+ credentials: "include",
117
+ body: JSON.stringify({ password: password ?? null }),
118
+ });
119
+
120
+ if (!response.ok) {
121
+ throw new Error(
122
+ await readErrorMessage(response, "Unable to delete account."),
123
+ );
124
+ }
125
+ }