create-zerotal 1.0.1 → 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.
Files changed (125) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/package.json +1 -1
  3. package/src/scaffold.ts +1 -1
  4. package/templates/admin/README.md +41 -0
  5. package/templates/admin/_env.example +17 -0
  6. package/templates/admin/app/admin/index.ts +6 -0
  7. package/templates/admin/app/auth/passwords.ts +79 -0
  8. package/templates/admin/config/database.ts +4 -3
  9. package/templates/admin/database/migrations/0001_create_password_reset_tokens_table.ts +26 -0
  10. package/templates/admin/package.json +9 -1
  11. package/templates/admin/tsconfig.json +7 -2
  12. package/templates/api/README.md +39 -0
  13. package/templates/api/_env.example +13 -0
  14. package/templates/api/app/controllers/AuthController.ts +11 -8
  15. package/templates/api/app/controllers/WelcomeController.ts +1 -1
  16. package/templates/api/app/middleware/RequireAuth.ts +1 -1
  17. package/templates/api/app/models/User.ts +2 -2
  18. package/templates/api/bootstrap/app.ts +6 -6
  19. package/templates/api/config/app.ts +1 -1
  20. package/templates/api/config/database.ts +12 -3
  21. package/templates/api/config/notifications.ts +1 -1
  22. package/templates/api/config/queue.ts +1 -1
  23. package/templates/api/config/session.ts +2 -2
  24. package/templates/api/database/migrations/0001_create_users_table.ts +8 -8
  25. package/templates/api/package.json +9 -1
  26. package/templates/api/routes/index.ts +23 -8
  27. package/templates/api/tests/auth.test.ts +6 -3
  28. package/templates/api/tests/helpers.ts +6 -6
  29. package/templates/api/tsconfig.json +7 -3
  30. package/templates/flow/README.md +39 -0
  31. package/templates/flow/_env.example +17 -0
  32. package/templates/flow/app/auth/passwords.ts +79 -0
  33. package/templates/flow/app/flow/layouts/app.tsx +10 -1
  34. package/templates/flow/app/flow/pages/forgot-password.tsx +69 -0
  35. package/templates/flow/app/flow/pages/login.tsx +88 -0
  36. package/templates/flow/app/flow/pages/profile.tsx +194 -0
  37. package/templates/flow/app/flow/pages/register.tsx +120 -0
  38. package/templates/flow/app/flow/pages/reset-password.tsx +103 -0
  39. package/templates/flow/app/flow/ui.ts +27 -0
  40. package/templates/flow/app/models/User.ts +17 -0
  41. package/templates/flow/bootstrap/app.ts +8 -0
  42. package/templates/flow/bootstrap/providers.ts +6 -1
  43. package/templates/flow/config/auth.ts +7 -0
  44. package/templates/flow/config/database.ts +18 -0
  45. package/templates/flow/config/session.ts +14 -0
  46. package/templates/flow/database/migrations/0001_create_users_table.ts +18 -0
  47. package/templates/flow/database/migrations/0002_create_password_reset_tokens_table.ts +23 -0
  48. package/templates/flow/package.json +10 -4
  49. package/templates/flow/tests/smoke.test.ts +21 -1
  50. package/templates/flow/tsconfig.json +10 -3
  51. package/templates/minimal/README.md +39 -0
  52. package/templates/minimal/_env.example +9 -0
  53. package/templates/minimal/bootstrap/app.ts +2 -2
  54. package/templates/minimal/package.json +10 -5
  55. package/templates/minimal/tests/smoke.test.ts +1 -1
  56. package/templates/minimal/tsconfig.json +13 -4
  57. package/templates/react/README.md +40 -0
  58. package/templates/react/_env.example +18 -0
  59. package/templates/react/app/auth/passwords.ts +79 -0
  60. package/templates/react/app/models/User.ts +17 -0
  61. package/templates/react/app/routes/forgot-password.ts +24 -0
  62. package/templates/react/app/routes/login.ts +32 -0
  63. package/templates/react/app/routes/logout.ts +12 -0
  64. package/templates/react/app/routes/profile/password.ts +30 -0
  65. package/templates/react/app/routes/profile.ts +39 -0
  66. package/templates/react/app/routes/register.ts +39 -0
  67. package/templates/react/app/routes/reset-password.ts +37 -0
  68. package/templates/react/bootstrap/app.ts +8 -0
  69. package/templates/react/bootstrap/providers.ts +7 -4
  70. package/templates/react/config/auth.ts +7 -0
  71. package/templates/react/config/database.ts +18 -0
  72. package/templates/react/database/migrations/0001_create_users_table.ts +18 -0
  73. package/templates/react/database/migrations/0002_create_password_reset_tokens_table.ts +23 -0
  74. package/templates/react/package.json +8 -2
  75. package/templates/react/resources/js/Layouts/AppLayout.tsx +12 -2
  76. package/templates/react/resources/js/pages/forgot-password.tsx +65 -0
  77. package/templates/react/resources/js/pages/login.tsx +92 -0
  78. package/templates/react/resources/js/pages/profile.tsx +147 -0
  79. package/templates/react/resources/js/pages/register.tsx +101 -0
  80. package/templates/react/resources/js/pages/reset-password.tsx +80 -0
  81. package/templates/react/resources/js/pages.generated.ts +5 -0
  82. package/templates/react/tests/smoke.test.ts +58 -2
  83. package/templates/react/tsconfig.json +10 -3
  84. package/templates/vue/README.md +40 -0
  85. package/templates/vue/_env.example +18 -0
  86. package/templates/vue/app/auth/passwords.ts +79 -0
  87. package/templates/vue/app/exceptions/Handler.ts +55 -0
  88. package/templates/vue/app/models/User.ts +17 -0
  89. package/templates/vue/app/routes/forgot-password.ts +24 -0
  90. package/templates/vue/app/routes/login.ts +32 -0
  91. package/templates/vue/app/routes/logout.ts +12 -0
  92. package/templates/vue/app/routes/profile/password.ts +30 -0
  93. package/templates/vue/app/routes/profile.ts +39 -0
  94. package/templates/vue/app/routes/register.ts +39 -0
  95. package/templates/vue/app/routes/reset-password.ts +37 -0
  96. package/templates/vue/bootstrap/app.ts +15 -3
  97. package/templates/vue/bootstrap/providers.ts +8 -1
  98. package/templates/vue/config/auth.ts +7 -0
  99. package/templates/vue/config/database.ts +18 -0
  100. package/templates/vue/config/session.ts +11 -0
  101. package/templates/vue/database/migrations/0001_create_users_table.ts +18 -0
  102. package/templates/vue/database/migrations/0002_create_password_reset_tokens_table.ts +23 -0
  103. package/templates/vue/package.json +8 -2
  104. package/templates/vue/public/zt.svg +17 -0
  105. package/templates/vue/resources/app.html +27 -1
  106. package/templates/vue/resources/css/app.css +205 -0
  107. package/templates/vue/resources/js/Components/Button.vue +49 -0
  108. package/templates/vue/resources/js/Components/Card.vue +23 -0
  109. package/templates/vue/resources/js/Components/FlashToasts.vue +81 -0
  110. package/templates/vue/resources/js/Components/Icon.vue +40 -0
  111. package/templates/vue/resources/js/Components/TextField.vue +66 -0
  112. package/templates/vue/resources/js/Components/ThemeToggle.vue +46 -0
  113. package/templates/vue/resources/js/Layouts/AppLayout.vue +140 -25
  114. package/templates/vue/resources/js/lib/cn.ts +13 -0
  115. package/templates/vue/resources/js/lib/site.ts +11 -0
  116. package/templates/vue/resources/js/pages/error.vue +47 -0
  117. package/templates/vue/resources/js/pages/forgot-password.vue +47 -0
  118. package/templates/vue/resources/js/pages/login.vue +67 -0
  119. package/templates/vue/resources/js/pages/profile.vue +119 -0
  120. package/templates/vue/resources/js/pages/register.vue +77 -0
  121. package/templates/vue/resources/js/pages/reset-password.vue +58 -0
  122. package/templates/vue/resources/js/pages.generated.ts +10 -4
  123. package/templates/vue/resources/js/types.ts +17 -0
  124. package/templates/vue/tests/smoke.test.ts +50 -1
  125. package/templates/vue/tsconfig.json +10 -3
@@ -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
+ }