@zerotal/admin 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +69 -0
- package/LICENSE +21 -0
- package/README.md +344 -0
- package/package.json +78 -0
- package/src/Cluster.ts +50 -0
- package/src/Panel.ts +288 -0
- package/src/PanelInstance.ts +644 -0
- package/src/Resource.ts +918 -0
- package/src/actions/Action.ts +607 -0
- package/src/actions/ImportRecordsJob.ts +108 -0
- package/src/actions/csv.ts +123 -0
- package/src/actions/index.ts +39 -0
- package/src/actions/render.tsx +181 -0
- package/src/actions/transfer.ts +307 -0
- package/src/actions/xlsx.ts +304 -0
- package/src/auth/AuthLayout.tsx +34 -0
- package/src/auth/index.ts +13 -0
- package/src/auth/pages/ForgotPasswordPage.tsx +87 -0
- package/src/auth/pages/LoginPage.tsx +121 -0
- package/src/auth/pages/ProfilePage.tsx +216 -0
- package/src/auth/pages/ResetPasswordPage.tsx +103 -0
- package/src/auth/pages/VerifyEmailPage.tsx +68 -0
- package/src/auth/register.ts +44 -0
- package/src/authRoles.ts +141 -0
- package/src/commands/MakeAdminResourceCommand.ts +181 -0
- package/src/config.ts +128 -0
- package/src/dashboardLayout.ts +101 -0
- package/src/databaseMedia.ts +148 -0
- package/src/databaseNotifications.ts +169 -0
- package/src/form/Field.ts +928 -0
- package/src/form/ResourceForm.ts +48 -0
- package/src/form/Section.ts +364 -0
- package/src/form/editors.ts +43 -0
- package/src/form/index.ts +59 -0
- package/src/history.ts +151 -0
- package/src/impersonation.ts +126 -0
- package/src/index.ts +380 -0
- package/src/infolist/Entry.ts +537 -0
- package/src/infolist/Section.ts +99 -0
- package/src/infolist/index.ts +38 -0
- package/src/media.ts +297 -0
- package/src/notifications.ts +65 -0
- package/src/pages/AdminPage.ts +100 -0
- package/src/pages/ConsolePage.tsx +324 -0
- package/src/pages/DashboardPage.tsx +264 -0
- package/src/pages/MediaPage.tsx +346 -0
- package/src/pages/NotificationsPage.tsx +155 -0
- package/src/pages/RecordViewPage.tsx +951 -0
- package/src/pages/ResourceFormPage.tsx +1856 -0
- package/src/pages/ResourceListPage.tsx +2552 -0
- package/src/pages/RolesPage.tsx +325 -0
- package/src/pages/SearchPage.tsx +169 -0
- package/src/plugin.ts +283 -0
- package/src/provider/AdminAbilityMiddleware.ts +25 -0
- package/src/provider/AdminGuardMiddleware.ts +29 -0
- package/src/provider/AdminProvider.ts +334 -0
- package/src/relations/RelationManager.ts +114 -0
- package/src/renderHooks.ts +86 -0
- package/src/roles.ts +175 -0
- package/src/savedViews.ts +79 -0
- package/src/support/ability.ts +73 -0
- package/src/support/authorize.ts +105 -0
- package/src/support/countCache.ts +37 -0
- package/src/support/hostPage.ts +30 -0
- package/src/table/Column.ts +353 -0
- package/src/table/Constraint.ts +238 -0
- package/src/table/Filter.ts +275 -0
- package/src/table/Group.ts +73 -0
- package/src/table/Tab.ts +77 -0
- package/src/testing.ts +121 -0
- package/src/theme.ts +70 -0
- package/src/ui/AdminLayout.tsx +355 -0
- package/src/ui/Breadcrumbs.tsx +84 -0
- package/src/ui/environmentIndicator.tsx +63 -0
- package/src/ui/icons.tsx +124 -0
- package/src/widgets/Widget.ts +251 -0
- package/src/widgets/render.tsx +154 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/** @jsxImportSource @zerotal/flow */
|
|
2
|
+
// "Forgot password" — collects an email and asks the app's broker to send a
|
|
3
|
+
// reset link. Mounted only when `auth.passwordReset` is configured.
|
|
4
|
+
|
|
5
|
+
import { Component, expose } from "@zerotal/flow";
|
|
6
|
+
import type { HtmlNode } from "@zerotal/flow";
|
|
7
|
+
import { Panel } from "../../Panel.ts";
|
|
8
|
+
import { AuthLayout } from "../AuthLayout.tsx";
|
|
9
|
+
import { Icon } from "../../ui/icons.tsx";
|
|
10
|
+
|
|
11
|
+
const INPUT =
|
|
12
|
+
"mt-1.5 block w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground outline-none transition placeholder:text-muted-foreground focus:ring-2 focus:ring-ring";
|
|
13
|
+
|
|
14
|
+
export class ForgotPasswordPage extends Component {
|
|
15
|
+
static layout = AuthLayout;
|
|
16
|
+
|
|
17
|
+
@expose email = "";
|
|
18
|
+
@expose sent = false;
|
|
19
|
+
@expose error = "";
|
|
20
|
+
|
|
21
|
+
@expose async send(): Promise<void> {
|
|
22
|
+
this.error = "";
|
|
23
|
+
const cfg = Panel.authConfig() ?? {};
|
|
24
|
+
if (!cfg.passwordReset) return;
|
|
25
|
+
const ok = await cfg.passwordReset.sendResetLink(this.email);
|
|
26
|
+
if (ok) this.sent = true;
|
|
27
|
+
else this.error = "We couldn't find an account with that email address.";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
override async render(): Promise<HtmlNode> {
|
|
31
|
+
const base = Panel.config().path.replace(/\/$/, "");
|
|
32
|
+
const loginPath = Panel.authConfig()?.loginPath ?? "/login";
|
|
33
|
+
|
|
34
|
+
if (this.sent) {
|
|
35
|
+
return (
|
|
36
|
+
<div class="space-y-4 text-center">
|
|
37
|
+
<div class="mx-auto flex h-11 w-11 items-center justify-center rounded-full bg-success/10 text-success">
|
|
38
|
+
<Icon name="mail" class="h-5 w-5" />
|
|
39
|
+
</div>
|
|
40
|
+
<div>
|
|
41
|
+
<h1 class="text-base font-semibold">Check your inbox</h1>
|
|
42
|
+
<p class="mt-1 text-sm text-muted-foreground">
|
|
43
|
+
If an account exists for {this.email}, a reset link is on its way.
|
|
44
|
+
</p>
|
|
45
|
+
</div>
|
|
46
|
+
<a
|
|
47
|
+
href={`${base}${loginPath}`}
|
|
48
|
+
navigate
|
|
49
|
+
class="inline-block text-sm font-medium text-primary hover:underline"
|
|
50
|
+
>
|
|
51
|
+
← Back to sign in
|
|
52
|
+
</a>
|
|
53
|
+
</div>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<form onSubmit={this.send} class="space-y-4">
|
|
59
|
+
<div>
|
|
60
|
+
<h1 class="text-base font-semibold">Reset your password</h1>
|
|
61
|
+
<p class="mt-0.5 text-sm text-muted-foreground">
|
|
62
|
+
We'll email you a link to set a new one.
|
|
63
|
+
</p>
|
|
64
|
+
</div>
|
|
65
|
+
{this.error ? <p class="text-sm text-destructive">{this.error}</p> : null}
|
|
66
|
+
<div>
|
|
67
|
+
<label class="block text-sm font-medium">Email</label>
|
|
68
|
+
<input type="email" value={this.email} placeholder="you@example.com" class={INPUT} />
|
|
69
|
+
</div>
|
|
70
|
+
<button
|
|
71
|
+
type="submit"
|
|
72
|
+
loadingAttr="disabled"
|
|
73
|
+
class="inline-flex h-10 w-full items-center justify-center rounded-lg bg-primary px-4 text-sm font-semibold text-primary-foreground shadow-sm transition hover:bg-primary/90 disabled:opacity-60"
|
|
74
|
+
>
|
|
75
|
+
Email reset link
|
|
76
|
+
</button>
|
|
77
|
+
<a
|
|
78
|
+
href={`${base}${loginPath}`}
|
|
79
|
+
navigate
|
|
80
|
+
class="block text-center text-sm font-medium text-primary hover:underline"
|
|
81
|
+
>
|
|
82
|
+
← Back to sign in
|
|
83
|
+
</a>
|
|
84
|
+
</form>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/** @jsxImportSource @zerotal/flow */
|
|
2
|
+
// Login screen — authenticates via `@zerotal/auth`'s `Auth.attempt`, then
|
|
3
|
+
// redirects into the panel. Mounted only when `Panel.auth({ enabled: true })`.
|
|
4
|
+
|
|
5
|
+
import { Component, expose } from "@zerotal/flow";
|
|
6
|
+
import type { HtmlNode } from "@zerotal/flow";
|
|
7
|
+
import { Auth } from "@zerotal/auth";
|
|
8
|
+
import { Panel } from "../../Panel.ts";
|
|
9
|
+
import { AuthLayout } from "../AuthLayout.tsx";
|
|
10
|
+
import { Icon } from "../../ui/icons.tsx";
|
|
11
|
+
|
|
12
|
+
const INPUT =
|
|
13
|
+
"mt-1.5 block w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground outline-none transition placeholder:text-muted-foreground focus:ring-2 focus:ring-ring";
|
|
14
|
+
|
|
15
|
+
export class LoginPage extends Component {
|
|
16
|
+
static layout = AuthLayout;
|
|
17
|
+
|
|
18
|
+
@expose email = "";
|
|
19
|
+
@expose password = "";
|
|
20
|
+
@expose remember = false;
|
|
21
|
+
@expose error = "";
|
|
22
|
+
|
|
23
|
+
@expose async login(): Promise<void> {
|
|
24
|
+
this.error = "";
|
|
25
|
+
const cfg = Panel.authConfig() ?? {};
|
|
26
|
+
const id = cfg.identifier ?? "email";
|
|
27
|
+
const creds: Record<string, unknown> = { [id]: this.email, password: this.password };
|
|
28
|
+
let ok: boolean;
|
|
29
|
+
if (cfg.authenticateWhen) {
|
|
30
|
+
ok = await Auth.attemptWhen(
|
|
31
|
+
creds,
|
|
32
|
+
cfg.authenticateWhen as (u: unknown) => boolean | Promise<boolean>,
|
|
33
|
+
!!this.remember,
|
|
34
|
+
);
|
|
35
|
+
} else {
|
|
36
|
+
ok = await Auth.attempt(creds, !!this.remember);
|
|
37
|
+
}
|
|
38
|
+
if (ok) {
|
|
39
|
+
const base = Panel.config().path.replace(/\/$/, "");
|
|
40
|
+
this.redirect(cfg.redirectTo ?? (base || "/")).withSuccess("Welcome back.");
|
|
41
|
+
} else {
|
|
42
|
+
this.error = "These credentials do not match our records.";
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
override async render(): Promise<HtmlNode> {
|
|
47
|
+
const cfg = Panel.authConfig() ?? {};
|
|
48
|
+
const base = Panel.config().path.replace(/\/$/, "");
|
|
49
|
+
const id = cfg.identifier ?? "email";
|
|
50
|
+
const idLabel = id === "email" ? "Email" : id.charAt(0).toUpperCase() + id.slice(1);
|
|
51
|
+
const showRemember = cfg.remember ?? true;
|
|
52
|
+
const showForgot = !!cfg.passwordReset;
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<form onSubmit={this.login} class="space-y-4">
|
|
56
|
+
<div>
|
|
57
|
+
<h1 class="text-base font-semibold">Sign in</h1>
|
|
58
|
+
<p class="mt-0.5 text-sm text-muted-foreground">Enter your credentials to continue.</p>
|
|
59
|
+
</div>
|
|
60
|
+
|
|
61
|
+
{this.error ? (
|
|
62
|
+
<div class="flex items-start gap-2 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive">
|
|
63
|
+
<Icon name="x-circle" class="mt-0.5 h-4 w-4 shrink-0" />
|
|
64
|
+
<span>{this.error}</span>
|
|
65
|
+
</div>
|
|
66
|
+
) : null}
|
|
67
|
+
|
|
68
|
+
<div>
|
|
69
|
+
<label class="block text-sm font-medium">{idLabel}</label>
|
|
70
|
+
<input
|
|
71
|
+
type={id === "email" ? "email" : "text"}
|
|
72
|
+
value={this.email}
|
|
73
|
+
autocomplete="username"
|
|
74
|
+
placeholder={id === "email" ? "you@example.com" : idLabel}
|
|
75
|
+
class={INPUT}
|
|
76
|
+
/>
|
|
77
|
+
</div>
|
|
78
|
+
|
|
79
|
+
<div>
|
|
80
|
+
<div class="flex items-center justify-between">
|
|
81
|
+
<label class="block text-sm font-medium">Password</label>
|
|
82
|
+
{showForgot ? (
|
|
83
|
+
<a
|
|
84
|
+
href={`${base}/forgot-password`}
|
|
85
|
+
navigate
|
|
86
|
+
class="text-xs font-medium text-primary hover:underline"
|
|
87
|
+
>
|
|
88
|
+
Forgot?
|
|
89
|
+
</a>
|
|
90
|
+
) : null}
|
|
91
|
+
</div>
|
|
92
|
+
<input
|
|
93
|
+
type="password"
|
|
94
|
+
value={this.password}
|
|
95
|
+
autocomplete="current-password"
|
|
96
|
+
class={INPUT}
|
|
97
|
+
/>
|
|
98
|
+
</div>
|
|
99
|
+
|
|
100
|
+
{showRemember ? (
|
|
101
|
+
<label class="flex items-center gap-2 text-sm text-muted-foreground">
|
|
102
|
+
<input
|
|
103
|
+
type="checkbox"
|
|
104
|
+
checked={this.remember}
|
|
105
|
+
class="h-4 w-4 rounded border-input text-primary focus:ring-2 focus:ring-ring"
|
|
106
|
+
/>
|
|
107
|
+
Remember me
|
|
108
|
+
</label>
|
|
109
|
+
) : null}
|
|
110
|
+
|
|
111
|
+
<button
|
|
112
|
+
type="submit"
|
|
113
|
+
loadingAttr="disabled"
|
|
114
|
+
class="inline-flex h-10 w-full items-center justify-center gap-1.5 rounded-lg bg-primary px-4 text-sm font-semibold text-primary-foreground shadow-sm transition hover:bg-primary/90 disabled:opacity-60"
|
|
115
|
+
>
|
|
116
|
+
Sign in
|
|
117
|
+
</button>
|
|
118
|
+
</form>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/** @jsxImportSource @zerotal/flow */
|
|
2
|
+
// In-panel profile page — update details, change password, and sign out. Uses
|
|
3
|
+
// `@zerotal/auth`'s `Auth` + `Hash`. Mounted behind the panel guard.
|
|
4
|
+
|
|
5
|
+
import { Component, expose } from "@zerotal/flow";
|
|
6
|
+
import type { HtmlNode } from "@zerotal/flow";
|
|
7
|
+
import type { HttpContext } from "@zerotal/core";
|
|
8
|
+
import { Auth, Hash } from "@zerotal/auth";
|
|
9
|
+
import { Panel } from "../../Panel.ts";
|
|
10
|
+
import { AdminLayout } from "../../ui/AdminLayout.tsx";
|
|
11
|
+
import { Icon } from "../../ui/icons.tsx";
|
|
12
|
+
|
|
13
|
+
const INPUT =
|
|
14
|
+
"mt-1.5 block w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground outline-none transition placeholder:text-muted-foreground focus:ring-2 focus:ring-ring";
|
|
15
|
+
const PRIMARY_BTN =
|
|
16
|
+
"inline-flex h-9 items-center gap-1.5 rounded-lg bg-primary px-4 text-sm font-semibold text-primary-foreground shadow-sm transition hover:bg-primary/90 disabled:opacity-60";
|
|
17
|
+
|
|
18
|
+
export class ProfilePage extends Component {
|
|
19
|
+
static layout = AdminLayout;
|
|
20
|
+
|
|
21
|
+
@expose name = "";
|
|
22
|
+
@expose email = "";
|
|
23
|
+
@expose currentPassword = "";
|
|
24
|
+
@expose newPassword = "";
|
|
25
|
+
@expose newPasswordConfirm = "";
|
|
26
|
+
@expose profileError = "";
|
|
27
|
+
@expose passwordError = "";
|
|
28
|
+
|
|
29
|
+
private _user(): Record<string, unknown> | undefined {
|
|
30
|
+
return Auth.userOrNull() as unknown as Record<string, unknown> | undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
override async onMount(_ctx?: HttpContext): Promise<void> {
|
|
34
|
+
const u = this._user();
|
|
35
|
+
if (u) {
|
|
36
|
+
this.name = String(u["name"] ?? "");
|
|
37
|
+
this.email = String(u["email"] ?? "");
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
@expose async saveProfile(): Promise<void> {
|
|
42
|
+
this.profileError = "";
|
|
43
|
+
const u = this._user();
|
|
44
|
+
if (!u) return;
|
|
45
|
+
const cfg = Panel.authConfig() ?? {};
|
|
46
|
+
const data = { name: this.name, email: this.email };
|
|
47
|
+
try {
|
|
48
|
+
if (cfg.updateProfile) {
|
|
49
|
+
await cfg.updateProfile(u, data);
|
|
50
|
+
} else {
|
|
51
|
+
const m = u as {
|
|
52
|
+
fill?: (d: Record<string, unknown>) => void;
|
|
53
|
+
save?: () => Promise<unknown>;
|
|
54
|
+
};
|
|
55
|
+
if (typeof m.fill === "function") m.fill(data);
|
|
56
|
+
else for (const [k, v] of Object.entries(data)) u[k] = v;
|
|
57
|
+
await m.save?.();
|
|
58
|
+
}
|
|
59
|
+
this.flash("Profile updated.");
|
|
60
|
+
} catch {
|
|
61
|
+
this.profileError = "Could not update your profile.";
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
@expose async changePassword(): Promise<void> {
|
|
66
|
+
this.passwordError = "";
|
|
67
|
+
const u = this._user();
|
|
68
|
+
if (!u) return;
|
|
69
|
+
if (this.newPassword.length < 8) {
|
|
70
|
+
this.passwordError = "New password must be at least 8 characters.";
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (this.newPassword !== this.newPasswordConfirm) {
|
|
74
|
+
this.passwordError = "The new passwords do not match.";
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const getHash = u["getAuthPassword"];
|
|
78
|
+
const hashed =
|
|
79
|
+
typeof getHash === "function" ? (getHash as () => string).call(u) : u["password"];
|
|
80
|
+
const ok = hashed ? await Hash.check(this.currentPassword, String(hashed)) : false;
|
|
81
|
+
if (!ok) {
|
|
82
|
+
this.passwordError = "Your current password is incorrect.";
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
u["password"] = await Hash.make(this.newPassword);
|
|
86
|
+
await (u as { save?: () => Promise<unknown> }).save?.();
|
|
87
|
+
this.currentPassword = "";
|
|
88
|
+
this.newPassword = "";
|
|
89
|
+
this.newPasswordConfirm = "";
|
|
90
|
+
this.flash("Password changed.");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
@expose async signOut(): Promise<void> {
|
|
94
|
+
await Auth.logout();
|
|
95
|
+
const cfg = Panel.authConfig() ?? {};
|
|
96
|
+
const base = Panel.config().path.replace(/\/$/, "");
|
|
97
|
+
this.redirect(`${base}${cfg.loginPath ?? "/login"}`).withSuccess("Signed out.");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
override async render(): Promise<HtmlNode> {
|
|
101
|
+
const base = Panel.config().path.replace(/\/$/, "");
|
|
102
|
+
const cfg = Panel.authConfig() ?? {};
|
|
103
|
+
const u = this._user();
|
|
104
|
+
const verify = cfg.emailVerification;
|
|
105
|
+
const unverified = !!verify && !!u && !verify.isVerified(u);
|
|
106
|
+
const card =
|
|
107
|
+
"rounded-xl border border-border bg-card p-5 text-card-foreground shadow-sm sm:p-6";
|
|
108
|
+
|
|
109
|
+
return (
|
|
110
|
+
<div class="mx-auto w-full max-w-2xl space-y-6">
|
|
111
|
+
<div class="flex flex-wrap items-end justify-between gap-4">
|
|
112
|
+
<div>
|
|
113
|
+
<nav class="mb-1 text-xs text-muted-foreground">
|
|
114
|
+
<a href={base} navigate class="hover:text-foreground">
|
|
115
|
+
Dashboard
|
|
116
|
+
</a>
|
|
117
|
+
<span class="px-1.5">/</span>
|
|
118
|
+
<span>Profile</span>
|
|
119
|
+
</nav>
|
|
120
|
+
<h1 class="text-2xl font-semibold tracking-tight">Your profile</h1>
|
|
121
|
+
</div>
|
|
122
|
+
<button
|
|
123
|
+
type="button"
|
|
124
|
+
onClick={this.signOut}
|
|
125
|
+
class="inline-flex h-9 items-center gap-1.5 rounded-lg border border-input bg-background px-3 text-sm font-medium transition hover:bg-accent hover:text-accent-foreground"
|
|
126
|
+
>
|
|
127
|
+
<Icon name="logout" class="h-4 w-4" /> Sign out
|
|
128
|
+
</button>
|
|
129
|
+
</div>
|
|
130
|
+
|
|
131
|
+
{unverified ? (
|
|
132
|
+
<div class="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-amber-500/30 bg-amber-500/5 px-4 py-3 text-sm">
|
|
133
|
+
<span class="flex items-center gap-2">
|
|
134
|
+
<Icon name="mail" class="h-4 w-4 text-amber-500" /> Your email address is not
|
|
135
|
+
verified.
|
|
136
|
+
</span>
|
|
137
|
+
<a
|
|
138
|
+
href={`${base}/verify-email`}
|
|
139
|
+
navigate
|
|
140
|
+
class="font-medium text-primary hover:underline"
|
|
141
|
+
>
|
|
142
|
+
Verify now →
|
|
143
|
+
</a>
|
|
144
|
+
</div>
|
|
145
|
+
) : null}
|
|
146
|
+
|
|
147
|
+
{/* Profile details */}
|
|
148
|
+
<form onSubmit={this.saveProfile} class={card}>
|
|
149
|
+
<h2 class="text-sm font-semibold">Account details</h2>
|
|
150
|
+
{this.profileError ? (
|
|
151
|
+
<p class="mt-2 text-sm text-destructive">{this.profileError}</p>
|
|
152
|
+
) : null}
|
|
153
|
+
<div class="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
154
|
+
<div>
|
|
155
|
+
<label class="block text-sm font-medium">Name</label>
|
|
156
|
+
<input value={this.name} class={INPUT} />
|
|
157
|
+
</div>
|
|
158
|
+
<div>
|
|
159
|
+
<label class="block text-sm font-medium">Email</label>
|
|
160
|
+
<input type="email" value={this.email} class={INPUT} />
|
|
161
|
+
</div>
|
|
162
|
+
</div>
|
|
163
|
+
<div class="mt-4 flex justify-end">
|
|
164
|
+
<button type="submit" loadingAttr="disabled" class={PRIMARY_BTN}>
|
|
165
|
+
Save changes
|
|
166
|
+
</button>
|
|
167
|
+
</div>
|
|
168
|
+
</form>
|
|
169
|
+
|
|
170
|
+
{/* Change password */}
|
|
171
|
+
<form onSubmit={this.changePassword} class={card}>
|
|
172
|
+
<h2 class="text-sm font-semibold">Change password</h2>
|
|
173
|
+
{this.passwordError ? (
|
|
174
|
+
<p class="mt-2 text-sm text-destructive">{this.passwordError}</p>
|
|
175
|
+
) : null}
|
|
176
|
+
<div class="mt-4 space-y-4">
|
|
177
|
+
<div>
|
|
178
|
+
<label class="block text-sm font-medium">Current password</label>
|
|
179
|
+
<input
|
|
180
|
+
type="password"
|
|
181
|
+
value={this.currentPassword}
|
|
182
|
+
autocomplete="current-password"
|
|
183
|
+
class={INPUT}
|
|
184
|
+
/>
|
|
185
|
+
</div>
|
|
186
|
+
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
187
|
+
<div>
|
|
188
|
+
<label class="block text-sm font-medium">New password</label>
|
|
189
|
+
<input
|
|
190
|
+
type="password"
|
|
191
|
+
value={this.newPassword}
|
|
192
|
+
autocomplete="new-password"
|
|
193
|
+
class={INPUT}
|
|
194
|
+
/>
|
|
195
|
+
</div>
|
|
196
|
+
<div>
|
|
197
|
+
<label class="block text-sm font-medium">Confirm new password</label>
|
|
198
|
+
<input
|
|
199
|
+
type="password"
|
|
200
|
+
value={this.newPasswordConfirm}
|
|
201
|
+
autocomplete="new-password"
|
|
202
|
+
class={INPUT}
|
|
203
|
+
/>
|
|
204
|
+
</div>
|
|
205
|
+
</div>
|
|
206
|
+
</div>
|
|
207
|
+
<div class="mt-4 flex justify-end">
|
|
208
|
+
<button type="submit" loadingAttr="disabled" class={PRIMARY_BTN}>
|
|
209
|
+
Update password
|
|
210
|
+
</button>
|
|
211
|
+
</div>
|
|
212
|
+
</form>
|
|
213
|
+
</div>
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/** @jsxImportSource @zerotal/flow */
|
|
2
|
+
// "Reset password" — consumes the token+email from the reset link query and asks
|
|
3
|
+
// the app's broker to set the new password.
|
|
4
|
+
|
|
5
|
+
import { Component, expose, url } from "@zerotal/flow";
|
|
6
|
+
import type { HtmlNode } from "@zerotal/flow";
|
|
7
|
+
import { Panel } from "../../Panel.ts";
|
|
8
|
+
import { AuthLayout } from "../AuthLayout.tsx";
|
|
9
|
+
import { Icon } from "../../ui/icons.tsx";
|
|
10
|
+
|
|
11
|
+
const INPUT =
|
|
12
|
+
"mt-1.5 block w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground outline-none transition placeholder:text-muted-foreground focus:ring-2 focus:ring-ring";
|
|
13
|
+
|
|
14
|
+
export class ResetPasswordPage extends Component {
|
|
15
|
+
static layout = AuthLayout;
|
|
16
|
+
|
|
17
|
+
/** Seeded from the reset link query (`?token=…&email=…`). */
|
|
18
|
+
@url token = "";
|
|
19
|
+
@url email = "";
|
|
20
|
+
@expose password = "";
|
|
21
|
+
@expose confirm = "";
|
|
22
|
+
@expose error = "";
|
|
23
|
+
@expose done = false;
|
|
24
|
+
|
|
25
|
+
@expose async reset(): Promise<void> {
|
|
26
|
+
this.error = "";
|
|
27
|
+
const cfg = Panel.authConfig() ?? {};
|
|
28
|
+
if (!cfg.passwordReset) return;
|
|
29
|
+
if (this.password.length < 8) {
|
|
30
|
+
this.error = "Password must be at least 8 characters.";
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
if (this.password !== this.confirm) {
|
|
34
|
+
this.error = "The passwords do not match.";
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
const ok = await cfg.passwordReset.reset({
|
|
38
|
+
email: this.email,
|
|
39
|
+
token: this.token,
|
|
40
|
+
password: this.password,
|
|
41
|
+
});
|
|
42
|
+
if (ok) this.done = true;
|
|
43
|
+
else this.error = "This reset link is invalid or has expired.";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
override async render(): Promise<HtmlNode> {
|
|
47
|
+
const base = Panel.config().path.replace(/\/$/, "");
|
|
48
|
+
const loginPath = Panel.authConfig()?.loginPath ?? "/login";
|
|
49
|
+
|
|
50
|
+
if (this.done) {
|
|
51
|
+
return (
|
|
52
|
+
<div class="space-y-4 text-center">
|
|
53
|
+
<div class="mx-auto flex h-11 w-11 items-center justify-center rounded-full bg-success/10 text-success">
|
|
54
|
+
<Icon name="check-circle" class="h-5 w-5" />
|
|
55
|
+
</div>
|
|
56
|
+
<div>
|
|
57
|
+
<h1 class="text-base font-semibold">Password updated</h1>
|
|
58
|
+
<p class="mt-1 text-sm text-muted-foreground">
|
|
59
|
+
You can now sign in with your new password.
|
|
60
|
+
</p>
|
|
61
|
+
</div>
|
|
62
|
+
<a
|
|
63
|
+
href={`${base}${loginPath}`}
|
|
64
|
+
navigate
|
|
65
|
+
class="inline-flex h-10 w-full items-center justify-center rounded-lg bg-primary px-4 text-sm font-semibold text-primary-foreground shadow-sm transition hover:bg-primary/90"
|
|
66
|
+
>
|
|
67
|
+
Go to sign in
|
|
68
|
+
</a>
|
|
69
|
+
</div>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<form onSubmit={this.reset} class="space-y-4">
|
|
75
|
+
<div>
|
|
76
|
+
<h1 class="text-base font-semibold">Choose a new password</h1>
|
|
77
|
+
{this.email ? <p class="mt-0.5 text-sm text-muted-foreground">for {this.email}</p> : null}
|
|
78
|
+
</div>
|
|
79
|
+
{this.error ? (
|
|
80
|
+
<div class="flex items-start gap-2 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive">
|
|
81
|
+
<Icon name="x-circle" class="mt-0.5 h-4 w-4 shrink-0" />
|
|
82
|
+
<span>{this.error}</span>
|
|
83
|
+
</div>
|
|
84
|
+
) : null}
|
|
85
|
+
<div>
|
|
86
|
+
<label class="block text-sm font-medium">New password</label>
|
|
87
|
+
<input type="password" value={this.password} autocomplete="new-password" class={INPUT} />
|
|
88
|
+
</div>
|
|
89
|
+
<div>
|
|
90
|
+
<label class="block text-sm font-medium">Confirm password</label>
|
|
91
|
+
<input type="password" value={this.confirm} autocomplete="new-password" class={INPUT} />
|
|
92
|
+
</div>
|
|
93
|
+
<button
|
|
94
|
+
type="submit"
|
|
95
|
+
loadingAttr="disabled"
|
|
96
|
+
class="inline-flex h-10 w-full items-center justify-center rounded-lg bg-primary px-4 text-sm font-semibold text-primary-foreground shadow-sm transition hover:bg-primary/90 disabled:opacity-60"
|
|
97
|
+
>
|
|
98
|
+
Reset password
|
|
99
|
+
</button>
|
|
100
|
+
</form>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/** @jsxImportSource @zerotal/flow */
|
|
2
|
+
// Email-verification notice — for a signed-in but unverified user. Re-sends the
|
|
3
|
+
// verification link via the app's hook. Mounted when `auth.emailVerification` is set.
|
|
4
|
+
|
|
5
|
+
import { Component, expose } from "@zerotal/flow";
|
|
6
|
+
import type { HtmlNode } from "@zerotal/flow";
|
|
7
|
+
import { Auth } from "@zerotal/auth";
|
|
8
|
+
import { Panel } from "../../Panel.ts";
|
|
9
|
+
import { AuthLayout } from "../AuthLayout.tsx";
|
|
10
|
+
import { Icon } from "../../ui/icons.tsx";
|
|
11
|
+
|
|
12
|
+
export class VerifyEmailPage extends Component {
|
|
13
|
+
static layout = AuthLayout;
|
|
14
|
+
|
|
15
|
+
@expose resent = false;
|
|
16
|
+
|
|
17
|
+
@expose async resend(): Promise<void> {
|
|
18
|
+
const cfg = Panel.authConfig() ?? {};
|
|
19
|
+
const u = Auth.userOrNull() as unknown;
|
|
20
|
+
if (cfg.emailVerification && u) {
|
|
21
|
+
await cfg.emailVerification.resend(u);
|
|
22
|
+
this.resent = true;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
@expose async signOut(): Promise<void> {
|
|
27
|
+
await Auth.logout();
|
|
28
|
+
const cfg = Panel.authConfig() ?? {};
|
|
29
|
+
const base = Panel.config().path.replace(/\/$/, "");
|
|
30
|
+
this.redirect(`${base}${cfg.loginPath ?? "/login"}`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
override async render(): Promise<HtmlNode> {
|
|
34
|
+
return (
|
|
35
|
+
<div class="space-y-4 text-center">
|
|
36
|
+
<div class="mx-auto flex h-11 w-11 items-center justify-center rounded-full bg-primary/10 text-primary">
|
|
37
|
+
<Icon name="mail" class="h-5 w-5" />
|
|
38
|
+
</div>
|
|
39
|
+
<div>
|
|
40
|
+
<h1 class="text-base font-semibold">Verify your email</h1>
|
|
41
|
+
<p class="mt-1 text-sm text-muted-foreground">
|
|
42
|
+
We've sent a verification link to your inbox. Click it to activate your account.
|
|
43
|
+
</p>
|
|
44
|
+
</div>
|
|
45
|
+
{this.resent ? (
|
|
46
|
+
<p class="flex items-center justify-center gap-1.5 text-sm text-success">
|
|
47
|
+
<Icon name="check-circle" class="h-4 w-4" /> A fresh link is on its way.
|
|
48
|
+
</p>
|
|
49
|
+
) : null}
|
|
50
|
+
<button
|
|
51
|
+
type="button"
|
|
52
|
+
onClick={this.resend}
|
|
53
|
+
loadingAttr="disabled"
|
|
54
|
+
class="inline-flex h-10 w-full items-center justify-center rounded-lg bg-primary px-4 text-sm font-semibold text-primary-foreground shadow-sm transition hover:bg-primary/90 disabled:opacity-60"
|
|
55
|
+
>
|
|
56
|
+
Resend verification email
|
|
57
|
+
</button>
|
|
58
|
+
<button
|
|
59
|
+
type="button"
|
|
60
|
+
onClick={this.signOut}
|
|
61
|
+
class="text-sm font-medium text-muted-foreground hover:text-foreground"
|
|
62
|
+
>
|
|
63
|
+
Sign out
|
|
64
|
+
</button>
|
|
65
|
+
</div>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth-route registrar. Dynamically imported by `AdminProvider` only when
|
|
3
|
+
* `Panel.auth({ enabled: true })` is set, so the hard `@zerotal/auth` dependency
|
|
4
|
+
* (pulled in by the page modules) stays out of the always-loaded path.
|
|
5
|
+
*
|
|
6
|
+
* Guest screens (login + optional password reset) mount OUTSIDE the panel guard
|
|
7
|
+
* so unauthenticated users can reach them; profile + verify mount behind the
|
|
8
|
+
* auth guard (defaults to the panel's middleware).
|
|
9
|
+
*/
|
|
10
|
+
import { Router } from "@zerotal/core";
|
|
11
|
+
import type { MiddlewareClass } from "@zerotal/core";
|
|
12
|
+
import { Panel } from "../Panel.ts";
|
|
13
|
+
import { LoginPage } from "./pages/LoginPage.tsx";
|
|
14
|
+
import { ProfilePage } from "./pages/ProfilePage.tsx";
|
|
15
|
+
import { ForgotPasswordPage } from "./pages/ForgotPasswordPage.tsx";
|
|
16
|
+
import { ResetPasswordPage } from "./pages/ResetPasswordPage.tsx";
|
|
17
|
+
import { VerifyEmailPage } from "./pages/VerifyEmailPage.tsx";
|
|
18
|
+
|
|
19
|
+
type FlowFn = (p: string, page: unknown, mw?: MiddlewareClass[]) => unknown;
|
|
20
|
+
|
|
21
|
+
export function registerAuthRoutes(path: string, panelGuard: MiddlewareClass[]): void {
|
|
22
|
+
const cfg = Panel.authConfig();
|
|
23
|
+
if (!cfg) return;
|
|
24
|
+
const flow = (Router as unknown as { flow?: FlowFn }).flow;
|
|
25
|
+
if (typeof flow !== "function") return;
|
|
26
|
+
|
|
27
|
+
const loginPath = cfg.loginPath ?? "/login";
|
|
28
|
+
const profilePath = cfg.profilePath ?? "/profile";
|
|
29
|
+
const guestGuard = cfg.guestMiddleware ?? [];
|
|
30
|
+
const authGuard = cfg.authMiddleware ?? panelGuard;
|
|
31
|
+
|
|
32
|
+
Router.group({ prefix: path, middleware: guestGuard }, () => {
|
|
33
|
+
flow(loginPath, LoginPage);
|
|
34
|
+
if (cfg.passwordReset) {
|
|
35
|
+
flow("/forgot-password", ForgotPasswordPage);
|
|
36
|
+
flow("/reset-password", ResetPasswordPage);
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
Router.group({ prefix: path, middleware: authGuard }, () => {
|
|
41
|
+
flow(profilePath, ProfilePage);
|
|
42
|
+
if (cfg.emailVerification) flow("/verify-email", VerifyEmailPage);
|
|
43
|
+
});
|
|
44
|
+
}
|