@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 +2 -2
- package/src/account-paths.ts +42 -0
- package/src/components/AccountLayout.tsx +119 -0
- package/src/hooks/useAccountSettings.ts +93 -0
- package/src/index.tsx +9 -0
- package/src/pages/DefaultDashboardPage.tsx +79 -40
- package/src/pages/SettingsPage.tsx +279 -0
- package/src/userPanelModule.tsx +31 -14
- package/src/utils/dev-portal-api.ts +125 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lukoweb/apitogo-module-user-panel",
|
|
3
|
-
"version": "0.1.
|
|
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.
|
|
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 ?? "
|
|
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="
|
|
47
|
+
<div className="px-8 py-7">
|
|
33
48
|
<Head>
|
|
34
49
|
<title>{title}</title>
|
|
35
50
|
</Head>
|
|
36
51
|
|
|
37
|
-
<div className="mb-
|
|
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
|
-
|
|
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
|
-
|
|
55
|
-
<
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
76
|
-
|
|
107
|
+
)}
|
|
108
|
+
</div>
|
|
77
109
|
</div>
|
|
78
|
-
|
|
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-
|
|
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
|
+
};
|
package/src/userPanelModule.tsx
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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: "
|
|
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
|
-
|
|
86
|
-
path: profilePath,
|
|
93
|
+
accountChildren.push({
|
|
94
|
+
path: toAccountChildPath(basePath, profilePath),
|
|
87
95
|
element: renderModuleElement(
|
|
88
96
|
options.userManagement.component,
|
|
89
|
-
<
|
|
97
|
+
<SettingsPage content={options.userManagement.content} />,
|
|
90
98
|
),
|
|
91
|
-
handle: { layout: "
|
|
99
|
+
handle: { layout: "none" },
|
|
92
100
|
});
|
|
93
101
|
protectAccountRoute(protectedRoutes, protectAccountRoutes, profilePath);
|
|
94
102
|
profileMenuItems.push({
|
|
95
|
-
label: "
|
|
103
|
+
label: "Settings",
|
|
96
104
|
path: profilePath,
|
|
97
105
|
category: "middle",
|
|
98
|
-
icon:
|
|
106
|
+
icon: SettingsIcon,
|
|
99
107
|
weight: 20,
|
|
100
108
|
});
|
|
101
109
|
}
|
|
102
110
|
|
|
103
111
|
if (options.plans.enabled) {
|
|
104
|
-
|
|
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: "
|
|
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
|
+
}
|