create-zerotal 1.0.0 → 1.0.2
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 +30 -0
- package/package.json +1 -1
- package/src/prompts.ts +6 -6
- package/src/scaffold.ts +9 -3
- package/templates/admin/README.md +41 -0
- package/templates/admin/_env.example +17 -0
- package/templates/admin/app/admin/index.ts +6 -0
- package/templates/admin/app/auth/passwords.ts +79 -0
- package/templates/admin/config/database.ts +4 -3
- package/templates/admin/database/migrations/0001_create_password_reset_tokens_table.ts +26 -0
- package/templates/admin/package.json +9 -1
- package/templates/admin/tsconfig.json +7 -2
- package/templates/api/README.md +39 -0
- package/templates/api/_env.example +13 -0
- package/templates/api/app/controllers/AuthController.ts +11 -8
- package/templates/api/app/controllers/WelcomeController.ts +1 -1
- package/templates/api/app/middleware/RequireAuth.ts +1 -1
- package/templates/api/app/models/User.ts +2 -2
- package/templates/api/bootstrap/app.ts +6 -6
- package/templates/api/config/app.ts +1 -1
- package/templates/api/config/database.ts +12 -3
- package/templates/api/config/notifications.ts +1 -1
- package/templates/api/config/queue.ts +1 -1
- package/templates/api/config/session.ts +2 -2
- package/templates/api/database/migrations/0001_create_users_table.ts +8 -8
- package/templates/api/package.json +9 -1
- package/templates/api/routes/index.ts +23 -8
- package/templates/api/tests/auth.test.ts +6 -3
- package/templates/api/tests/helpers.ts +6 -6
- package/templates/api/tsconfig.json +7 -3
- package/templates/flow/README.md +39 -0
- package/templates/flow/_env.example +17 -0
- package/templates/flow/app/auth/passwords.ts +79 -0
- package/templates/flow/app/flow/layouts/app.tsx +10 -1
- package/templates/flow/app/flow/pages/forgot-password.tsx +69 -0
- package/templates/flow/app/flow/pages/login.tsx +88 -0
- package/templates/flow/app/flow/pages/profile.tsx +194 -0
- package/templates/flow/app/flow/pages/register.tsx +120 -0
- package/templates/flow/app/flow/pages/reset-password.tsx +103 -0
- package/templates/flow/app/flow/ui.ts +27 -0
- package/templates/flow/app/models/User.ts +17 -0
- package/templates/flow/bootstrap/app.ts +8 -0
- package/templates/flow/bootstrap/providers.ts +6 -1
- package/templates/flow/config/auth.ts +7 -0
- package/templates/flow/config/database.ts +18 -0
- package/templates/flow/config/session.ts +14 -0
- package/templates/flow/database/migrations/0001_create_users_table.ts +18 -0
- package/templates/flow/database/migrations/0002_create_password_reset_tokens_table.ts +23 -0
- package/templates/flow/package.json +10 -4
- package/templates/flow/tests/smoke.test.ts +21 -1
- package/templates/flow/tsconfig.json +10 -3
- package/templates/minimal/README.md +39 -0
- package/templates/minimal/_env.example +9 -0
- package/templates/minimal/bootstrap/app.ts +2 -2
- package/templates/minimal/package.json +10 -5
- package/templates/minimal/tests/smoke.test.ts +1 -1
- package/templates/minimal/tsconfig.json +13 -4
- package/templates/react/README.md +40 -0
- package/templates/react/_env.example +18 -0
- package/templates/react/app/auth/passwords.ts +79 -0
- package/templates/react/app/models/User.ts +17 -0
- package/templates/react/app/routes/forgot-password.ts +24 -0
- package/templates/react/app/routes/login.ts +32 -0
- package/templates/react/app/routes/logout.ts +12 -0
- package/templates/react/app/routes/profile/password.ts +30 -0
- package/templates/react/app/routes/profile.ts +39 -0
- package/templates/react/app/routes/register.ts +39 -0
- package/templates/react/app/routes/reset-password.ts +37 -0
- package/templates/react/bootstrap/app.ts +8 -0
- package/templates/react/bootstrap/providers.ts +7 -4
- package/templates/react/config/auth.ts +7 -0
- package/templates/react/config/database.ts +18 -0
- package/templates/react/database/migrations/0001_create_users_table.ts +18 -0
- package/templates/react/database/migrations/0002_create_password_reset_tokens_table.ts +23 -0
- package/templates/react/package.json +8 -2
- package/templates/react/resources/js/Layouts/AppLayout.tsx +12 -2
- package/templates/react/resources/js/pages/forgot-password.tsx +65 -0
- package/templates/react/resources/js/pages/login.tsx +92 -0
- package/templates/react/resources/js/pages/profile.tsx +147 -0
- package/templates/react/resources/js/pages/register.tsx +101 -0
- package/templates/react/resources/js/pages/reset-password.tsx +80 -0
- package/templates/react/resources/js/pages.generated.ts +5 -0
- package/templates/react/tests/smoke.test.ts +58 -2
- package/templates/react/tsconfig.json +10 -3
- package/templates/vue/README.md +40 -0
- package/templates/vue/_env.example +18 -0
- package/templates/vue/app/auth/passwords.ts +79 -0
- package/templates/vue/app/exceptions/Handler.ts +55 -0
- package/templates/vue/app/models/User.ts +17 -0
- package/templates/vue/app/routes/forgot-password.ts +24 -0
- package/templates/vue/app/routes/login.ts +32 -0
- package/templates/vue/app/routes/logout.ts +12 -0
- package/templates/vue/app/routes/profile/password.ts +30 -0
- package/templates/vue/app/routes/profile.ts +39 -0
- package/templates/vue/app/routes/register.ts +39 -0
- package/templates/vue/app/routes/reset-password.ts +37 -0
- package/templates/vue/bootstrap/app.ts +15 -3
- package/templates/vue/bootstrap/providers.ts +8 -1
- package/templates/vue/config/auth.ts +7 -0
- package/templates/vue/config/database.ts +18 -0
- package/templates/vue/config/session.ts +11 -0
- package/templates/vue/database/migrations/0001_create_users_table.ts +18 -0
- package/templates/vue/database/migrations/0002_create_password_reset_tokens_table.ts +23 -0
- package/templates/vue/package.json +8 -2
- package/templates/vue/public/zt.svg +17 -0
- package/templates/vue/resources/app.html +27 -1
- package/templates/vue/resources/css/app.css +205 -0
- package/templates/vue/resources/js/Components/Button.vue +49 -0
- package/templates/vue/resources/js/Components/Card.vue +23 -0
- package/templates/vue/resources/js/Components/FlashToasts.vue +81 -0
- package/templates/vue/resources/js/Components/Icon.vue +40 -0
- package/templates/vue/resources/js/Components/TextField.vue +66 -0
- package/templates/vue/resources/js/Components/ThemeToggle.vue +46 -0
- package/templates/vue/resources/js/Layouts/AppLayout.vue +140 -25
- package/templates/vue/resources/js/lib/cn.ts +13 -0
- package/templates/vue/resources/js/lib/site.ts +11 -0
- package/templates/vue/resources/js/pages/error.vue +47 -0
- package/templates/vue/resources/js/pages/forgot-password.vue +47 -0
- package/templates/vue/resources/js/pages/login.vue +67 -0
- package/templates/vue/resources/js/pages/profile.vue +119 -0
- package/templates/vue/resources/js/pages/register.vue +77 -0
- package/templates/vue/resources/js/pages/reset-password.vue +58 -0
- package/templates/vue/resources/js/pages.generated.ts +10 -4
- package/templates/vue/resources/js/types.ts +17 -0
- package/templates/vue/tests/smoke.test.ts +50 -1
- package/templates/vue/tsconfig.json +10 -3
|
@@ -7,8 +7,12 @@
|
|
|
7
7
|
"exactOptionalPropertyTypes": true,
|
|
8
8
|
"noUncheckedIndexedAccess": true,
|
|
9
9
|
"emitDecoratorMetadata": false,
|
|
10
|
-
"skipLibCheck": true
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"noEmit": true,
|
|
12
|
+
"allowImportingTsExtensions": true
|
|
11
13
|
},
|
|
12
|
-
"
|
|
13
|
-
|
|
14
|
+
"exclude": [
|
|
15
|
+
"node_modules",
|
|
16
|
+
".zerotal"
|
|
17
|
+
]
|
|
14
18
|
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# {{name}}
|
|
2
|
+
|
|
3
|
+
A server-driven UI built with Flow: pages render on the server and stream patches over a WebSocket, so there is no API to maintain.
|
|
4
|
+
|
|
5
|
+
Built with [Zerotal](https://zerotal.dev) — a full-stack TypeScript framework for Bun.
|
|
6
|
+
|
|
7
|
+
## Requirements
|
|
8
|
+
|
|
9
|
+
- [Bun](https://bun.sh) 1.3.14 or newer. Node.js is not supported: Zerotal uses
|
|
10
|
+
Bun-native APIs throughout.
|
|
11
|
+
|
|
12
|
+
## Getting started
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
bun install
|
|
16
|
+
bun run dev
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
The dev server prints a local URL and a network one, so you can open the app on
|
|
20
|
+
another device on the same Wi-Fi.
|
|
21
|
+
|
|
22
|
+
## Scripts
|
|
23
|
+
|
|
24
|
+
| Command | What it does |
|
|
25
|
+
| --- | --- |
|
|
26
|
+
| `bun run dev` | Start the dev server with hot reload |
|
|
27
|
+
| `bun run start` | Start the server without dev tooling |
|
|
28
|
+
| `bun run test` | Run the test suite |
|
|
29
|
+
| `bun run typecheck` | Type-check without emitting |
|
|
30
|
+
|
|
31
|
+
## Configuration
|
|
32
|
+
|
|
33
|
+
Environment variables live in `.env`, documented in `.env.example`. `APP_KEY`
|
|
34
|
+
was generated for this project when it was scaffolded — keep it out of version
|
|
35
|
+
control, and use a different one per environment.
|
|
36
|
+
|
|
37
|
+
## Documentation
|
|
38
|
+
|
|
39
|
+
Full documentation is at [zerotal.dev/docs](https://zerotal.dev/docs).
|
|
@@ -1,2 +1,19 @@
|
|
|
1
|
+
# The runtime environment. "production" turns on the framework's stricter
|
|
2
|
+
# config checks and turns off verbose error output.
|
|
1
3
|
APP_ENV=development
|
|
4
|
+
|
|
5
|
+
# Signs sessions, cookies and encrypted values. Generated per project — never
|
|
6
|
+
# reuse one between environments, and never commit a real key.
|
|
2
7
|
APP_KEY={{app_key}}
|
|
8
|
+
|
|
9
|
+
# Canonical base URL. Used for absolute links, and to recognise your own origin
|
|
10
|
+
# when the app runs behind a reverse proxy.
|
|
11
|
+
APP_URL=http://localhost:3000
|
|
12
|
+
|
|
13
|
+
# Where the user table lives. Must match the driver in config/database.ts.
|
|
14
|
+
#
|
|
15
|
+
# Left commented on purpose. config/database.ts already points at this file, and
|
|
16
|
+
# `bun zt test` treats a set DATABASE_URL as the test database too — so setting
|
|
17
|
+
# it here would have the suite mutate your development data instead of running
|
|
18
|
+
# against a throwaway :memory: schema. Uncomment only to point somewhere else.
|
|
19
|
+
# DATABASE_URL=./database/db.sqlite
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { PasswordBroker, Hash } from "zerotal/auth";
|
|
2
|
+
import { DB } from "zerotal/orm";
|
|
3
|
+
import { Log } from "zerotal/logger";
|
|
4
|
+
import { User } from "@app/models/User";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Password-reset broker for the forgot/reset pages.
|
|
8
|
+
*
|
|
9
|
+
* The broker owns the security-sensitive half — generating the token, hashing
|
|
10
|
+
* it before storage, expiring it, and comparing candidates in constant time.
|
|
11
|
+
* Everything below is the part only this app can answer: where tokens live, how
|
|
12
|
+
* a message reaches the user, and what "set the password" means for our model.
|
|
13
|
+
*/
|
|
14
|
+
const broker = new PasswordBroker({
|
|
15
|
+
expireMinutes: 60,
|
|
16
|
+
|
|
17
|
+
async findToken(email) {
|
|
18
|
+
const row = await DB.table("password_reset_tokens").where("email", email).first();
|
|
19
|
+
if (!row) return null;
|
|
20
|
+
return {
|
|
21
|
+
token: String((row as Record<string, unknown>)["token"]),
|
|
22
|
+
createdAt: new Date(String((row as Record<string, unknown>)["created_at"])),
|
|
23
|
+
};
|
|
24
|
+
},
|
|
25
|
+
|
|
26
|
+
async storeToken(email, hash) {
|
|
27
|
+
// One live token per address: a second request invalidates the first rather
|
|
28
|
+
// than leaving two working links in someone's inbox.
|
|
29
|
+
await DB.table("password_reset_tokens").where("email", email).delete();
|
|
30
|
+
await DB.table("password_reset_tokens").insert({
|
|
31
|
+
email,
|
|
32
|
+
token: hash,
|
|
33
|
+
created_at: new Date().toISOString(),
|
|
34
|
+
});
|
|
35
|
+
},
|
|
36
|
+
|
|
37
|
+
async deleteToken(email) {
|
|
38
|
+
await DB.table("password_reset_tokens").where("email", email).delete();
|
|
39
|
+
},
|
|
40
|
+
|
|
41
|
+
async pruneTokens(cutoff) {
|
|
42
|
+
await DB.table("password_reset_tokens").where("created_at", "<", cutoff.toISOString()).delete();
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
async sendResetLink(email, token) {
|
|
46
|
+
const url = `${process.env["APP_URL"] ?? "http://localhost:3000"}/reset-password?token=${token}&email=${encodeURIComponent(email)}`;
|
|
47
|
+
|
|
48
|
+
// Logged rather than emailed, so a freshly scaffolded app has a working
|
|
49
|
+
// reset flow before any mail server exists. To send it for real, add
|
|
50
|
+
// `@zerotal/notifications` and swap this line for a Mail send — the token
|
|
51
|
+
// is the only thing this callback needs to deliver.
|
|
52
|
+
Log.info(`[password reset] ${email} → ${url}`);
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
async resetPassword(email, newPassword) {
|
|
56
|
+
const user = await User.query().where("email", email).first();
|
|
57
|
+
if (!user) return;
|
|
58
|
+
user.password = await Hash.make(newPassword);
|
|
59
|
+
await user.save();
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Adapter matching the panel's `passwordReset` contract, which wants booleans
|
|
65
|
+
* rather than the broker's result strings.
|
|
66
|
+
*/
|
|
67
|
+
export const passwordReset = {
|
|
68
|
+
async sendResetLink(email: string): Promise<boolean> {
|
|
69
|
+
// Always reports success. Answering "no such account" here would turn the
|
|
70
|
+
// forgot-password form into a way to test which addresses are registered.
|
|
71
|
+
await broker.sendResetLink(email);
|
|
72
|
+
return true;
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
async reset(input: { email: string; token: string; password: string }): Promise<boolean> {
|
|
76
|
+
const result = await broker.reset(input.token, input.email, input.password);
|
|
77
|
+
return result === "passwords.reset";
|
|
78
|
+
},
|
|
79
|
+
};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Layout, Link } from "@zerotal/flow";
|
|
2
2
|
import type { HtmlNode } from "@zerotal/flow";
|
|
3
3
|
import { asset } from "zerotal/assets";
|
|
4
|
+
import { Auth } from "zerotal/auth";
|
|
4
5
|
|
|
5
6
|
const NAV = [
|
|
6
7
|
{ href: "/", label: "Home" },
|
|
@@ -8,6 +9,14 @@ const NAV = [
|
|
|
8
9
|
{ href: "/contact", label: "Contact" },
|
|
9
10
|
];
|
|
10
11
|
|
|
12
|
+
// Shown to signed-in visitors and guests respectively, so the header always
|
|
13
|
+
// offers the next useful step rather than a link that would bounce them.
|
|
14
|
+
const AUTHED_NAV = [{ href: "/profile", label: "Profile" }];
|
|
15
|
+
const GUEST_NAV = [
|
|
16
|
+
{ href: "/login", label: "Sign in" },
|
|
17
|
+
{ href: "/register", label: "Register" },
|
|
18
|
+
];
|
|
19
|
+
|
|
11
20
|
export class AppLayout extends Layout {
|
|
12
21
|
// Built by `bun zt serve` from resources/css/app.css (Tailwind) → public/app.css.
|
|
13
22
|
// A getter (not a static field) so asset() re-runs per request and picks up
|
|
@@ -36,7 +45,7 @@ export class AppLayout extends Layout {
|
|
|
36
45
|
</Link>
|
|
37
46
|
|
|
38
47
|
<div class="flex items-center gap-1">
|
|
39
|
-
{NAV.map((item) => (
|
|
48
|
+
{[...NAV, ...(Auth.check() ? AUTHED_NAV : GUEST_NAV)].map((item) => (
|
|
40
49
|
<Link href={item.href} hover class={navLink}>
|
|
41
50
|
{item.label}
|
|
42
51
|
</Link>
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { Component, expose, Link } from "@zerotal/flow";
|
|
2
|
+
import type { HtmlNode } from "@zerotal/flow";
|
|
3
|
+
import { GuestMiddleware } from "zerotal/auth";
|
|
4
|
+
import { passwordReset } from "@app/auth/passwords";
|
|
5
|
+
import { AppLayout } from "../layouts/app";
|
|
6
|
+
import { FIELD, LABEL, PRIMARY, CARD, NOTICE } from "../ui";
|
|
7
|
+
|
|
8
|
+
export const middleware = [GuestMiddleware];
|
|
9
|
+
|
|
10
|
+
export class ForgotPasswordPage extends Component {
|
|
11
|
+
static layout = AppLayout;
|
|
12
|
+
static title = "Reset your password";
|
|
13
|
+
|
|
14
|
+
@expose email = "";
|
|
15
|
+
@expose sent = false;
|
|
16
|
+
|
|
17
|
+
@expose async send(): Promise<void> {
|
|
18
|
+
await passwordReset.sendResetLink(this.email);
|
|
19
|
+
|
|
20
|
+
// Always the same outcome, whether or not the address has an account.
|
|
21
|
+
// Reporting "no such user" here would make this form a way to enumerate
|
|
22
|
+
// which addresses are registered.
|
|
23
|
+
this.sent = true;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async render(): Promise<HtmlNode> {
|
|
27
|
+
return (
|
|
28
|
+
<section class={CARD}>
|
|
29
|
+
<h1 class="text-2xl font-bold tracking-tight">Reset your password</h1>
|
|
30
|
+
<p class="mt-1 text-sm text-gray-600">
|
|
31
|
+
Give us the address you signed up with and we will send a link.
|
|
32
|
+
</p>
|
|
33
|
+
|
|
34
|
+
{this.sent ? (
|
|
35
|
+
<p class={`${NOTICE} mt-6`}>
|
|
36
|
+
If that address has an account, a reset link is on its way. In development the link is
|
|
37
|
+
written to the server log rather than emailed.
|
|
38
|
+
</p>
|
|
39
|
+
) : (
|
|
40
|
+
<form onSubmit={this.send} class="mt-6 space-y-4">
|
|
41
|
+
<div>
|
|
42
|
+
<label class={LABEL} for="email">
|
|
43
|
+
Email
|
|
44
|
+
</label>
|
|
45
|
+
<input
|
|
46
|
+
id="email"
|
|
47
|
+
type="email"
|
|
48
|
+
autocomplete="email"
|
|
49
|
+
required
|
|
50
|
+
class={FIELD}
|
|
51
|
+
value={this.email}
|
|
52
|
+
/>
|
|
53
|
+
</div>
|
|
54
|
+
|
|
55
|
+
<button type="submit" class={PRIMARY}>
|
|
56
|
+
Send reset link
|
|
57
|
+
</button>
|
|
58
|
+
</form>
|
|
59
|
+
)}
|
|
60
|
+
|
|
61
|
+
<p class="mt-6 text-sm text-gray-600">
|
|
62
|
+
<Link href="/login" class="text-indigo-600 hover:underline">
|
|
63
|
+
Back to sign in
|
|
64
|
+
</Link>
|
|
65
|
+
</p>
|
|
66
|
+
</section>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { Component, expose, Link } from "@zerotal/flow";
|
|
2
|
+
import type { HtmlNode } from "@zerotal/flow";
|
|
3
|
+
import { Auth, GuestMiddleware } from "zerotal/auth";
|
|
4
|
+
import { AppLayout } from "../layouts/app";
|
|
5
|
+
import { FIELD, LABEL, PRIMARY, CARD, ERROR } from "../ui";
|
|
6
|
+
|
|
7
|
+
// Signed-in visitors are sent away from the sign-in form rather than shown it.
|
|
8
|
+
export const middleware = [GuestMiddleware];
|
|
9
|
+
|
|
10
|
+
export class LoginPage extends Component {
|
|
11
|
+
static layout = AppLayout;
|
|
12
|
+
static title = "Sign in";
|
|
13
|
+
|
|
14
|
+
@expose email = "";
|
|
15
|
+
@expose password = "";
|
|
16
|
+
@expose remember = false;
|
|
17
|
+
@expose error = "";
|
|
18
|
+
|
|
19
|
+
@expose async login(): Promise<void> {
|
|
20
|
+
this.error = "";
|
|
21
|
+
|
|
22
|
+
const ok = await Auth.attempt(
|
|
23
|
+
{ email: this.email, password: this.password },
|
|
24
|
+
Boolean(this.remember),
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
if (!ok) {
|
|
28
|
+
// One message for a wrong address and a wrong password alike — saying
|
|
29
|
+
// which was wrong turns this form into a way to discover valid accounts.
|
|
30
|
+
this.error = "Those credentials do not match our records.";
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
this.redirect("/profile").withSuccess("Welcome back.");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async render(): Promise<HtmlNode> {
|
|
38
|
+
return (
|
|
39
|
+
<section class={CARD}>
|
|
40
|
+
<h1 class="text-2xl font-bold tracking-tight">Sign in</h1>
|
|
41
|
+
<p class="mt-1 text-sm text-gray-600">Enter your details to continue.</p>
|
|
42
|
+
|
|
43
|
+
<form onSubmit={this.login} class="mt-6 space-y-4">
|
|
44
|
+
{this.error ? <p class={ERROR}>{this.error}</p> : null}
|
|
45
|
+
|
|
46
|
+
<div>
|
|
47
|
+
<label class={LABEL} for="email">
|
|
48
|
+
Email
|
|
49
|
+
</label>
|
|
50
|
+
<input id="email" type="email" autocomplete="email" required class={FIELD} value={this.email} />
|
|
51
|
+
</div>
|
|
52
|
+
|
|
53
|
+
<div>
|
|
54
|
+
<label class={LABEL} for="password">
|
|
55
|
+
Password
|
|
56
|
+
</label>
|
|
57
|
+
<input
|
|
58
|
+
id="password"
|
|
59
|
+
type="password"
|
|
60
|
+
autocomplete="current-password"
|
|
61
|
+
required
|
|
62
|
+
class={FIELD}
|
|
63
|
+
value={this.password}
|
|
64
|
+
/>
|
|
65
|
+
</div>
|
|
66
|
+
|
|
67
|
+
<label class="flex items-center gap-2 text-sm text-gray-600">
|
|
68
|
+
<input type="checkbox" checked={this.remember} class="rounded border-gray-300" />
|
|
69
|
+
Remember me
|
|
70
|
+
</label>
|
|
71
|
+
|
|
72
|
+
<button type="submit" class={PRIMARY}>
|
|
73
|
+
Sign in
|
|
74
|
+
</button>
|
|
75
|
+
</form>
|
|
76
|
+
|
|
77
|
+
<div class="mt-6 flex items-center justify-between text-sm">
|
|
78
|
+
<Link href="/forgot-password" class="text-indigo-600 hover:underline">
|
|
79
|
+
Forgot your password?
|
|
80
|
+
</Link>
|
|
81
|
+
<Link href="/register" class="text-indigo-600 hover:underline">
|
|
82
|
+
Create an account
|
|
83
|
+
</Link>
|
|
84
|
+
</div>
|
|
85
|
+
</section>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { Component, expose } from "@zerotal/flow";
|
|
2
|
+
import type { HtmlNode } from "@zerotal/flow";
|
|
3
|
+
import { Auth, AuthMiddleware, Hash } from "zerotal/auth";
|
|
4
|
+
import { User } from "@app/models/User";
|
|
5
|
+
import { AppLayout } from "../layouts/app";
|
|
6
|
+
import { FIELD, LABEL, PRIMARY, SECONDARY, CARD, ERROR, NOTICE } from "../ui";
|
|
7
|
+
|
|
8
|
+
// Only for signed-in visitors; everyone else is sent to the sign-in form.
|
|
9
|
+
export const middleware = [AuthMiddleware];
|
|
10
|
+
|
|
11
|
+
export class ProfilePage extends Component {
|
|
12
|
+
static layout = AppLayout;
|
|
13
|
+
static title = "Your profile";
|
|
14
|
+
|
|
15
|
+
@expose name = "";
|
|
16
|
+
@expose email = "";
|
|
17
|
+
@expose detailsError = "";
|
|
18
|
+
@expose detailsSaved = false;
|
|
19
|
+
|
|
20
|
+
@expose currentPassword = "";
|
|
21
|
+
@expose password = "";
|
|
22
|
+
@expose passwordConfirmation = "";
|
|
23
|
+
@expose passwordError = "";
|
|
24
|
+
@expose passwordSaved = false;
|
|
25
|
+
|
|
26
|
+
/** Seed the form from the signed-in user on first render. */
|
|
27
|
+
async onMount(): Promise<void> {
|
|
28
|
+
const user = Auth.user() as User;
|
|
29
|
+
this.name = user.name;
|
|
30
|
+
this.email = user.email;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
@expose async saveDetails(): Promise<void> {
|
|
34
|
+
this.detailsError = "";
|
|
35
|
+
this.detailsSaved = false;
|
|
36
|
+
|
|
37
|
+
const user = Auth.user() as User;
|
|
38
|
+
|
|
39
|
+
if (this.name.trim() === "" || this.email.trim() === "") {
|
|
40
|
+
this.detailsError = "Name and email are required.";
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Someone else holding this address is the one case that must be rejected;
|
|
45
|
+
// keeping your own is not a conflict.
|
|
46
|
+
const taken = await User.query().where("email", this.email).first();
|
|
47
|
+
if (taken && taken.id !== user.id) {
|
|
48
|
+
this.detailsError = "That email is already in use.";
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
user.name = this.name;
|
|
53
|
+
user.email = this.email;
|
|
54
|
+
await user.save();
|
|
55
|
+
|
|
56
|
+
this.detailsSaved = true;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
@expose async changePassword(): Promise<void> {
|
|
60
|
+
this.passwordError = "";
|
|
61
|
+
this.passwordSaved = false;
|
|
62
|
+
|
|
63
|
+
const user = Auth.user() as User;
|
|
64
|
+
|
|
65
|
+
// Proving the current password is what stops a borrowed session from
|
|
66
|
+
// locking the real owner out of their account.
|
|
67
|
+
if (!(await Hash.check(this.currentPassword, user.password))) {
|
|
68
|
+
this.passwordError = "Your current password is not correct.";
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (this.password.length < 8) {
|
|
72
|
+
this.passwordError = "Choose a password of at least 8 characters.";
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (this.password !== this.passwordConfirmation) {
|
|
76
|
+
this.passwordError = "The two passwords do not match.";
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
user.password = await Hash.make(this.password);
|
|
81
|
+
await user.save();
|
|
82
|
+
|
|
83
|
+
this.currentPassword = "";
|
|
84
|
+
this.password = "";
|
|
85
|
+
this.passwordConfirmation = "";
|
|
86
|
+
this.passwordSaved = true;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
@expose async signOut(): Promise<void> {
|
|
90
|
+
await Auth.logout();
|
|
91
|
+
this.redirect("/login").withSuccess("Signed out.");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async render(): Promise<HtmlNode> {
|
|
95
|
+
return (
|
|
96
|
+
<div class="mx-auto w-full max-w-lg space-y-6">
|
|
97
|
+
<div>
|
|
98
|
+
<h1 class="text-3xl font-bold tracking-tight">Your profile</h1>
|
|
99
|
+
<p class="mt-1 text-gray-600">Update your details or change your password.</p>
|
|
100
|
+
</div>
|
|
101
|
+
|
|
102
|
+
<section class={CARD}>
|
|
103
|
+
<h2 class="text-lg font-semibold">Details</h2>
|
|
104
|
+
|
|
105
|
+
<form onSubmit={this.saveDetails} class="mt-4 space-y-4">
|
|
106
|
+
{this.detailsError ? <p class={ERROR}>{this.detailsError}</p> : null}
|
|
107
|
+
{this.detailsSaved ? <p class={NOTICE}>Details saved.</p> : null}
|
|
108
|
+
|
|
109
|
+
<div>
|
|
110
|
+
<label class={LABEL} for="name">
|
|
111
|
+
Name
|
|
112
|
+
</label>
|
|
113
|
+
<input id="name" type="text" required class={FIELD} value={this.name} />
|
|
114
|
+
</div>
|
|
115
|
+
|
|
116
|
+
<div>
|
|
117
|
+
<label class={LABEL} for="email">
|
|
118
|
+
Email
|
|
119
|
+
</label>
|
|
120
|
+
<input id="email" type="email" required class={FIELD} value={this.email} />
|
|
121
|
+
</div>
|
|
122
|
+
|
|
123
|
+
<button type="submit" class={PRIMARY}>
|
|
124
|
+
Save details
|
|
125
|
+
</button>
|
|
126
|
+
</form>
|
|
127
|
+
</section>
|
|
128
|
+
|
|
129
|
+
<section class={CARD}>
|
|
130
|
+
<h2 class="text-lg font-semibold">Password</h2>
|
|
131
|
+
|
|
132
|
+
<form onSubmit={this.changePassword} class="mt-4 space-y-4">
|
|
133
|
+
{this.passwordError ? <p class={ERROR}>{this.passwordError}</p> : null}
|
|
134
|
+
{this.passwordSaved ? <p class={NOTICE}>Password updated.</p> : null}
|
|
135
|
+
|
|
136
|
+
<div>
|
|
137
|
+
<label class={LABEL} for="current_password">
|
|
138
|
+
Current password
|
|
139
|
+
</label>
|
|
140
|
+
<input
|
|
141
|
+
id="current_password"
|
|
142
|
+
type="password"
|
|
143
|
+
autocomplete="current-password"
|
|
144
|
+
required
|
|
145
|
+
class={FIELD}
|
|
146
|
+
value={this.currentPassword}
|
|
147
|
+
/>
|
|
148
|
+
</div>
|
|
149
|
+
|
|
150
|
+
<div>
|
|
151
|
+
<label class={LABEL} for="password">
|
|
152
|
+
New password
|
|
153
|
+
</label>
|
|
154
|
+
<input
|
|
155
|
+
id="password"
|
|
156
|
+
type="password"
|
|
157
|
+
autocomplete="new-password"
|
|
158
|
+
required
|
|
159
|
+
class={FIELD}
|
|
160
|
+
value={this.password}
|
|
161
|
+
/>
|
|
162
|
+
</div>
|
|
163
|
+
|
|
164
|
+
<div>
|
|
165
|
+
<label class={LABEL} for="password_confirmation">
|
|
166
|
+
Confirm new password
|
|
167
|
+
</label>
|
|
168
|
+
<input
|
|
169
|
+
id="password_confirmation"
|
|
170
|
+
type="password"
|
|
171
|
+
autocomplete="new-password"
|
|
172
|
+
required
|
|
173
|
+
class={FIELD}
|
|
174
|
+
value={this.passwordConfirmation}
|
|
175
|
+
/>
|
|
176
|
+
</div>
|
|
177
|
+
|
|
178
|
+
<button type="submit" class={PRIMARY}>
|
|
179
|
+
Change password
|
|
180
|
+
</button>
|
|
181
|
+
</form>
|
|
182
|
+
</section>
|
|
183
|
+
|
|
184
|
+
<section class={CARD}>
|
|
185
|
+
<h2 class="text-lg font-semibold">Session</h2>
|
|
186
|
+
<p class="mt-1 text-sm text-gray-600">Sign out of this browser.</p>
|
|
187
|
+
<button type="button" onClick={this.signOut} class={`${SECONDARY} mt-4`}>
|
|
188
|
+
Sign out
|
|
189
|
+
</button>
|
|
190
|
+
</section>
|
|
191
|
+
</div>
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { Component, expose, Link } from "@zerotal/flow";
|
|
2
|
+
import type { HtmlNode } from "@zerotal/flow";
|
|
3
|
+
import { Auth, GuestMiddleware, Hash } from "zerotal/auth";
|
|
4
|
+
import { User } from "@app/models/User";
|
|
5
|
+
import { AppLayout } from "../layouts/app";
|
|
6
|
+
import { FIELD, LABEL, PRIMARY, CARD, ERROR } from "../ui";
|
|
7
|
+
|
|
8
|
+
export const middleware = [GuestMiddleware];
|
|
9
|
+
|
|
10
|
+
export class RegisterPage extends Component {
|
|
11
|
+
static layout = AppLayout;
|
|
12
|
+
static title = "Create an account";
|
|
13
|
+
|
|
14
|
+
@expose name = "";
|
|
15
|
+
@expose email = "";
|
|
16
|
+
@expose password = "";
|
|
17
|
+
@expose passwordConfirmation = "";
|
|
18
|
+
@expose error = "";
|
|
19
|
+
|
|
20
|
+
@expose async register(): Promise<void> {
|
|
21
|
+
this.error = "";
|
|
22
|
+
|
|
23
|
+
if (this.name.trim() === "" || this.email.trim() === "") {
|
|
24
|
+
this.error = "Name and email are required.";
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
if (this.password.length < 8) {
|
|
28
|
+
this.error = "Choose a password of at least 8 characters.";
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
if (this.password !== this.passwordConfirmation) {
|
|
32
|
+
this.error = "The two passwords do not match.";
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const taken = await User.query().where("email", this.email).first();
|
|
37
|
+
if (taken) {
|
|
38
|
+
this.error = "That email is already registered.";
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// `role` is not fillable, so it cannot arrive from the form at all — it is
|
|
43
|
+
// set here, in code, which is the whole point of leaving it off `fillable`.
|
|
44
|
+
const user = new User();
|
|
45
|
+
user.name = this.name;
|
|
46
|
+
user.email = this.email;
|
|
47
|
+
user.password = await Hash.make(this.password);
|
|
48
|
+
user.role = "user";
|
|
49
|
+
await user.save();
|
|
50
|
+
|
|
51
|
+
await Auth.attempt({ email: this.email, password: this.password });
|
|
52
|
+
this.redirect("/profile").withSuccess("Welcome aboard.");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async render(): Promise<HtmlNode> {
|
|
56
|
+
return (
|
|
57
|
+
<section class={CARD}>
|
|
58
|
+
<h1 class="text-2xl font-bold tracking-tight">Create an account</h1>
|
|
59
|
+
<p class="mt-1 text-sm text-gray-600">It takes about ten seconds.</p>
|
|
60
|
+
|
|
61
|
+
<form onSubmit={this.register} class="mt-6 space-y-4">
|
|
62
|
+
{this.error ? <p class={ERROR}>{this.error}</p> : null}
|
|
63
|
+
|
|
64
|
+
<div>
|
|
65
|
+
<label class={LABEL} for="name">
|
|
66
|
+
Name
|
|
67
|
+
</label>
|
|
68
|
+
<input id="name" type="text" autocomplete="name" required class={FIELD} value={this.name} />
|
|
69
|
+
</div>
|
|
70
|
+
|
|
71
|
+
<div>
|
|
72
|
+
<label class={LABEL} for="email">
|
|
73
|
+
Email
|
|
74
|
+
</label>
|
|
75
|
+
<input id="email" type="email" autocomplete="email" required class={FIELD} value={this.email} />
|
|
76
|
+
</div>
|
|
77
|
+
|
|
78
|
+
<div>
|
|
79
|
+
<label class={LABEL} for="password">
|
|
80
|
+
Password
|
|
81
|
+
</label>
|
|
82
|
+
<input
|
|
83
|
+
id="password"
|
|
84
|
+
type="password"
|
|
85
|
+
autocomplete="new-password"
|
|
86
|
+
required
|
|
87
|
+
class={FIELD}
|
|
88
|
+
value={this.password}
|
|
89
|
+
/>
|
|
90
|
+
</div>
|
|
91
|
+
|
|
92
|
+
<div>
|
|
93
|
+
<label class={LABEL} for="password_confirmation">
|
|
94
|
+
Confirm password
|
|
95
|
+
</label>
|
|
96
|
+
<input
|
|
97
|
+
id="password_confirmation"
|
|
98
|
+
type="password"
|
|
99
|
+
autocomplete="new-password"
|
|
100
|
+
required
|
|
101
|
+
class={FIELD}
|
|
102
|
+
value={this.passwordConfirmation}
|
|
103
|
+
/>
|
|
104
|
+
</div>
|
|
105
|
+
|
|
106
|
+
<button type="submit" class={PRIMARY}>
|
|
107
|
+
Create account
|
|
108
|
+
</button>
|
|
109
|
+
</form>
|
|
110
|
+
|
|
111
|
+
<p class="mt-6 text-sm text-gray-600">
|
|
112
|
+
Already registered?{" "}
|
|
113
|
+
<Link href="/login" class="text-indigo-600 hover:underline">
|
|
114
|
+
Sign in
|
|
115
|
+
</Link>
|
|
116
|
+
</p>
|
|
117
|
+
</section>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
}
|