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
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { Component, expose, url, 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, ERROR, NOTICE } from "../ui";
|
|
7
|
+
|
|
8
|
+
export const middleware = [GuestMiddleware];
|
|
9
|
+
|
|
10
|
+
export class ResetPasswordPage extends Component {
|
|
11
|
+
static layout = AppLayout;
|
|
12
|
+
static title = "Choose a new password";
|
|
13
|
+
|
|
14
|
+
/** Seeded from the reset link's query string (`?token=…&email=…`). */
|
|
15
|
+
@url token = "";
|
|
16
|
+
@url email = "";
|
|
17
|
+
|
|
18
|
+
@expose password = "";
|
|
19
|
+
@expose passwordConfirmation = "";
|
|
20
|
+
@expose error = "";
|
|
21
|
+
@expose done = false;
|
|
22
|
+
|
|
23
|
+
@expose async reset(): Promise<void> {
|
|
24
|
+
this.error = "";
|
|
25
|
+
|
|
26
|
+
if (this.password.length < 8) {
|
|
27
|
+
this.error = "Choose a password of at least 8 characters.";
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
if (this.password !== this.passwordConfirmation) {
|
|
31
|
+
this.error = "The two passwords do not match.";
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const ok = await passwordReset.reset({
|
|
36
|
+
email: this.email,
|
|
37
|
+
token: this.token,
|
|
38
|
+
password: this.password,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
if (ok) this.done = true;
|
|
42
|
+
else this.error = "This reset link is invalid or has expired.";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async render(): Promise<HtmlNode> {
|
|
46
|
+
if (this.done) {
|
|
47
|
+
return (
|
|
48
|
+
<section class={CARD}>
|
|
49
|
+
<h1 class="text-2xl font-bold tracking-tight">Password updated</h1>
|
|
50
|
+
<p class={`${NOTICE} mt-4`}>You can now sign in with your new password.</p>
|
|
51
|
+
<p class="mt-6 text-sm text-gray-600">
|
|
52
|
+
<Link href="/login" class="text-indigo-600 hover:underline">
|
|
53
|
+
Go to sign in
|
|
54
|
+
</Link>
|
|
55
|
+
</p>
|
|
56
|
+
</section>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<section class={CARD}>
|
|
62
|
+
<h1 class="text-2xl font-bold tracking-tight">Choose a new password</h1>
|
|
63
|
+
<p class="mt-1 text-sm text-gray-600">Resetting the password for {this.email}.</p>
|
|
64
|
+
|
|
65
|
+
<form onSubmit={this.reset} class="mt-6 space-y-4">
|
|
66
|
+
{this.error ? <p class={ERROR}>{this.error}</p> : null}
|
|
67
|
+
|
|
68
|
+
<div>
|
|
69
|
+
<label class={LABEL} for="password">
|
|
70
|
+
New password
|
|
71
|
+
</label>
|
|
72
|
+
<input
|
|
73
|
+
id="password"
|
|
74
|
+
type="password"
|
|
75
|
+
autocomplete="new-password"
|
|
76
|
+
required
|
|
77
|
+
class={FIELD}
|
|
78
|
+
value={this.password}
|
|
79
|
+
/>
|
|
80
|
+
</div>
|
|
81
|
+
|
|
82
|
+
<div>
|
|
83
|
+
<label class={LABEL} for="password_confirmation">
|
|
84
|
+
Confirm password
|
|
85
|
+
</label>
|
|
86
|
+
<input
|
|
87
|
+
id="password_confirmation"
|
|
88
|
+
type="password"
|
|
89
|
+
autocomplete="new-password"
|
|
90
|
+
required
|
|
91
|
+
class={FIELD}
|
|
92
|
+
value={this.passwordConfirmation}
|
|
93
|
+
/>
|
|
94
|
+
</div>
|
|
95
|
+
|
|
96
|
+
<button type="submit" class={PRIMARY}>
|
|
97
|
+
Update password
|
|
98
|
+
</button>
|
|
99
|
+
</form>
|
|
100
|
+
</section>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared form styling, so every auth screen looks like the same application.
|
|
3
|
+
*
|
|
4
|
+
* Plain string constants rather than components: Flow renders on the server and
|
|
5
|
+
* these are only class lists, so a constant is the whole abstraction needed.
|
|
6
|
+
*/
|
|
7
|
+
export const CARD = "mx-auto w-full max-w-sm rounded-2xl border border-gray-200/70 bg-white p-8 shadow-sm";
|
|
8
|
+
|
|
9
|
+
export const LABEL = "block text-sm font-medium text-gray-900";
|
|
10
|
+
|
|
11
|
+
export const FIELD =
|
|
12
|
+
"mt-1.5 block w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 " +
|
|
13
|
+
"outline-none transition placeholder:text-gray-400 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/30";
|
|
14
|
+
|
|
15
|
+
export const PRIMARY =
|
|
16
|
+
"w-full rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white transition " +
|
|
17
|
+
"hover:bg-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/40 disabled:opacity-60";
|
|
18
|
+
|
|
19
|
+
export const SECONDARY =
|
|
20
|
+
"rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-semibold text-gray-700 " +
|
|
21
|
+
"transition hover:bg-gray-50";
|
|
22
|
+
|
|
23
|
+
export const ERROR =
|
|
24
|
+
"rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700";
|
|
25
|
+
|
|
26
|
+
export const NOTICE =
|
|
27
|
+
"rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-700";
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { BaseModel, column, table } from "zerotal/orm";
|
|
2
|
+
|
|
3
|
+
@table("users")
|
|
4
|
+
export class User extends BaseModel {
|
|
5
|
+
// Models guard every attribute by default. Only these may be mass-assigned
|
|
6
|
+
// from a request body — `role` is deliberately absent, so no amount of extra
|
|
7
|
+
// fields in a form post can promote the account that submitted it.
|
|
8
|
+
static override fillable = ["name", "email", "password"];
|
|
9
|
+
|
|
10
|
+
// Never serialised into a rendered page or a JSON response.
|
|
11
|
+
static override hidden = ["password"];
|
|
12
|
+
|
|
13
|
+
@column({ type: "string" }) name!: string;
|
|
14
|
+
@column({ type: "string" }) email!: string;
|
|
15
|
+
@column({ type: "string" }) password!: string;
|
|
16
|
+
@column({ type: "string", nullable: true, default: "user" }) role?: string;
|
|
17
|
+
}
|
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import { Application, basePath } from "zerotal";
|
|
2
2
|
import providers from "./providers";
|
|
3
|
+
import type { User } from "../app/models/User.ts";
|
|
4
|
+
|
|
5
|
+
// Tell @zerotal/auth which model `Auth.user()` returns. The empty body is the
|
|
6
|
+
// point: this augmentation exists to bind the type, not to add members.
|
|
7
|
+
declare module "zerotal/auth" {
|
|
8
|
+
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
|
9
|
+
interface UserModel extends User {}
|
|
10
|
+
}
|
|
3
11
|
|
|
4
12
|
const app = Application.create({ providers }).fileBasedRouting({
|
|
5
13
|
web: basePath("app/flow/pages"),
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { LogProvider } from "zerotal/logger";
|
|
2
|
+
import { DatabaseProvider } from "zerotal/orm";
|
|
3
|
+
import { SessionProvider } from "zerotal/session";
|
|
4
|
+
import { AuthProvider } from "zerotal/auth";
|
|
2
5
|
import { FlowProvider } from "@zerotal/flow";
|
|
3
6
|
|
|
4
|
-
|
|
7
|
+
// Order matters: the database backs the user model, and the session underpins
|
|
8
|
+
// auth — AuthProvider needs both already registered when it boots.
|
|
9
|
+
const providers = [LogProvider, DatabaseProvider, SessionProvider, AuthProvider, FlowProvider];
|
|
5
10
|
|
|
6
11
|
export default providers;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { env } from "zerotal";
|
|
2
|
+
import { DatabaseConfig } from "zerotal/orm";
|
|
3
|
+
|
|
4
|
+
export default DatabaseConfig({
|
|
5
|
+
// The driver has to agree with the URL's protocol — boot validation rejects a
|
|
6
|
+
// sqlite driver pointed at postgres:// or mysql://. Change both together.
|
|
7
|
+
driver: "sqlite",
|
|
8
|
+
|
|
9
|
+
// ZT_DB_URL first: `bun zt test` sets it (defaults to :memory:) and that
|
|
10
|
+
// override has to beat the DATABASE_URL in .env, or the suite would run
|
|
11
|
+
// against your development database and leave its rows behind.
|
|
12
|
+
url: env("ZT_DB_URL", env("DATABASE_URL", "./database/db.sqlite")),
|
|
13
|
+
|
|
14
|
+
// Builds the schema from the models at boot, so `bun run dev` works with no
|
|
15
|
+
// migration step. Hard-off in production, where you run the migrations in
|
|
16
|
+
// database/migrations instead.
|
|
17
|
+
synchronize: env("APP_ENV", "development") !== "production",
|
|
18
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { env } from "zerotal";
|
|
2
|
+
import { SessionConfig } from "zerotal/session";
|
|
3
|
+
|
|
4
|
+
export default SessionConfig({
|
|
5
|
+
driver: "cookie",
|
|
6
|
+
// Cookie sessions are signed, so the driver needs the app key.
|
|
7
|
+
secret: env("APP_KEY", ""),
|
|
8
|
+
cookie: "{{name}}_session",
|
|
9
|
+
lifetime: 60 * 60 * 24 * 7, // 7 days
|
|
10
|
+
// Tied to the scheme rather than hardcoded: a `secure` cookie is never sent
|
|
11
|
+
// over plain HTTP, so setting it unconditionally would drop the session in
|
|
12
|
+
// local development. The framework refuses to boot production without it.
|
|
13
|
+
secure: env("APP_URL", "http://localhost:3000").startsWith("https://"),
|
|
14
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Migration, Schema } from "zerotal/orm";
|
|
2
|
+
|
|
3
|
+
export default class CreateUsersTable extends Migration {
|
|
4
|
+
async up(): Promise<void> {
|
|
5
|
+
await Schema.create("users", (table) => {
|
|
6
|
+
table.increments("id");
|
|
7
|
+
table.string("name");
|
|
8
|
+
table.string("email").unique();
|
|
9
|
+
table.string("password");
|
|
10
|
+
table.string("role").nullable().default("user");
|
|
11
|
+
table.timestamps();
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async down(): Promise<void> {
|
|
16
|
+
await Schema.drop("users");
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { Migration, Schema } from "zerotal/orm";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* One live reset token per address, keyed by email rather than user id so a
|
|
5
|
+
* request for an unknown address does the same work as a known one — the
|
|
6
|
+
* response must not reveal which addresses have accounts.
|
|
7
|
+
*
|
|
8
|
+
* `token` holds a SHA-256 hash, never the value that was sent: a leaked
|
|
9
|
+
* database should not hand out working reset links.
|
|
10
|
+
*/
|
|
11
|
+
export default class CreatePasswordResetTokensTable extends Migration {
|
|
12
|
+
async up(): Promise<void> {
|
|
13
|
+
await Schema.create("password_reset_tokens", (table) => {
|
|
14
|
+
table.string("email").primary();
|
|
15
|
+
table.string("token");
|
|
16
|
+
table.timestamp("created_at");
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async down(): Promise<void> {
|
|
21
|
+
await Schema.drop("password_reset_tokens");
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -7,14 +7,20 @@
|
|
|
7
7
|
"zt": "bun zt.ts",
|
|
8
8
|
"dev": "bun zt.ts serve --dev",
|
|
9
9
|
"start": "bun zt.ts serve",
|
|
10
|
-
"test": "bun zt.ts test"
|
|
10
|
+
"test": "bun zt.ts test",
|
|
11
|
+
"typecheck": "tsc --noEmit"
|
|
11
12
|
},
|
|
12
13
|
"dependencies": {
|
|
13
14
|
"@zerotal/flow": "{{zerotal_version}}",
|
|
14
|
-
"zerotal": "{{zerotal_version}}"
|
|
15
|
+
"zerotal": "{{zerotal_version}}",
|
|
16
|
+
"tailwindcss": "^4.0.0",
|
|
17
|
+
"bun-plugin-tailwind": "latest"
|
|
18
|
+
},
|
|
19
|
+
"engines": {
|
|
20
|
+
"bun": ">=1.3.14"
|
|
15
21
|
},
|
|
16
22
|
"devDependencies": {
|
|
17
|
-
"
|
|
18
|
-
"
|
|
23
|
+
"typescript": "^5.8.0",
|
|
24
|
+
"@types/bun": "^1.3.14"
|
|
19
25
|
}
|
|
20
26
|
}
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* does rather than that it exists.
|
|
9
9
|
*/
|
|
10
10
|
import { beforeAll, afterAll, describe, test, expect } from 'bun:test';
|
|
11
|
-
import { createTestApp, type TestApp } from '
|
|
11
|
+
import { createTestApp, type TestApp } from 'zerotal/testing';
|
|
12
12
|
import { FlowTest } from '@zerotal/flow/testing';
|
|
13
13
|
import { ContactPage } from '../app/flow/pages/contact.tsx';
|
|
14
14
|
|
|
@@ -19,6 +19,8 @@ beforeAll(async () => {
|
|
|
19
19
|
// deterministic one for tests; `??=` leaves a real key alone when the app's
|
|
20
20
|
// .env is present.
|
|
21
21
|
Bun.env.APP_KEY ??= 'test-app-key-aaaaaaaaaaaaaaaaaaaaaaaa';
|
|
22
|
+
// Each run gets its own throwaway schema rather than the dev database.
|
|
23
|
+
Bun.env.ZT_DB_URL ??= ':memory:';
|
|
22
24
|
app = await createTestApp(() => import('../bootstrap/app.ts').then((m) => m.default));
|
|
23
25
|
});
|
|
24
26
|
|
|
@@ -54,3 +56,21 @@ describe('the contact page component', () => {
|
|
|
54
56
|
page.assertSee('hello@example.com');
|
|
55
57
|
});
|
|
56
58
|
});
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The auth surface. The guard is the assertion that matters: /profile must be
|
|
62
|
+
* unreachable without a session, however the page itself is built.
|
|
63
|
+
*/
|
|
64
|
+
describe('auth', () => {
|
|
65
|
+
test('serves the guest screens', async () => {
|
|
66
|
+
(await app.get('/login')).assertOk();
|
|
67
|
+
(await app.get('/register')).assertOk();
|
|
68
|
+
(await app.get('/forgot-password')).assertOk();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('keeps the profile behind a sign-in', async () => {
|
|
72
|
+
const res = await app.get('/profile');
|
|
73
|
+
|
|
74
|
+
expect(res.status).toBe(302);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -11,8 +11,15 @@
|
|
|
11
11
|
"jsxImportSource": "@zerotal/flow",
|
|
12
12
|
"skipLibCheck": true,
|
|
13
13
|
"paths": {
|
|
14
|
-
"@app/*": [
|
|
15
|
-
|
|
14
|
+
"@app/*": [
|
|
15
|
+
"./app/*"
|
|
16
|
+
]
|
|
17
|
+
},
|
|
18
|
+
"noEmit": true,
|
|
19
|
+
"allowImportingTsExtensions": true
|
|
16
20
|
},
|
|
17
|
-
"exclude": [
|
|
21
|
+
"exclude": [
|
|
22
|
+
"node_modules",
|
|
23
|
+
".zerotal"
|
|
24
|
+
]
|
|
18
25
|
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# {{name}}
|
|
2
|
+
|
|
3
|
+
A single-page starter on the bare framework — JSX views and Tailwind, nothing else.
|
|
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,11 @@
|
|
|
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
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { Application, basePath } from '
|
|
2
|
-
import { LogProvider } from '
|
|
1
|
+
import { Application, basePath } from 'zerotal';
|
|
2
|
+
import { LogProvider } from 'zerotal/logger';
|
|
3
3
|
|
|
4
4
|
const app = Application.create({ providers: [LogProvider] })
|
|
5
5
|
.routing({
|
|
@@ -7,14 +7,19 @@
|
|
|
7
7
|
"zt": "bun zt.ts",
|
|
8
8
|
"dev": "bun zt.ts serve --dev",
|
|
9
9
|
"start": "bun zt.ts serve",
|
|
10
|
-
"test": "bun zt.ts test"
|
|
10
|
+
"test": "bun zt.ts test",
|
|
11
|
+
"typecheck": "tsc --noEmit"
|
|
11
12
|
},
|
|
12
13
|
"dependencies": {
|
|
13
|
-
"
|
|
14
|
-
"
|
|
14
|
+
"zerotal": "{{zerotal_version}}",
|
|
15
|
+
"tailwindcss": "^4.0.0",
|
|
16
|
+
"bun-plugin-tailwind": "latest"
|
|
17
|
+
},
|
|
18
|
+
"engines": {
|
|
19
|
+
"bun": ">=1.3.14"
|
|
15
20
|
},
|
|
16
21
|
"devDependencies": {
|
|
17
|
-
"
|
|
18
|
-
"
|
|
22
|
+
"typescript": "^5.8.0",
|
|
23
|
+
"@types/bun": "^1.3.14"
|
|
19
24
|
}
|
|
20
25
|
}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* you have real tests, or keep it as the shape to copy.
|
|
8
8
|
*/
|
|
9
9
|
import { beforeAll, afterAll, describe, test, expect } from 'bun:test';
|
|
10
|
-
import { createTestApp, type TestApp } from '
|
|
10
|
+
import { createTestApp, type TestApp } from 'zerotal/testing';
|
|
11
11
|
|
|
12
12
|
let app: TestApp;
|
|
13
13
|
|
|
@@ -11,9 +11,18 @@
|
|
|
11
11
|
"jsxImportSource": "zerotal",
|
|
12
12
|
"skipLibCheck": true,
|
|
13
13
|
"paths": {
|
|
14
|
-
"@app/*": [
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
"@app/*": [
|
|
15
|
+
"./app/*"
|
|
16
|
+
],
|
|
17
|
+
"@pages/*": [
|
|
18
|
+
"./resources/js/pages/*"
|
|
19
|
+
]
|
|
20
|
+
},
|
|
21
|
+
"noEmit": true,
|
|
22
|
+
"allowImportingTsExtensions": true
|
|
17
23
|
},
|
|
18
|
-
"exclude": [
|
|
24
|
+
"exclude": [
|
|
25
|
+
"node_modules",
|
|
26
|
+
".zerotal"
|
|
27
|
+
]
|
|
19
28
|
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# {{name}}
|
|
2
|
+
|
|
3
|
+
An Inertia + React single-page app with file-based routes and Tailwind.
|
|
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
|
+
| `bun run build` | Build the frontend bundle for production |
|
|
31
|
+
|
|
32
|
+
## Configuration
|
|
33
|
+
|
|
34
|
+
Environment variables live in `.env`, documented in `.env.example`. `APP_KEY`
|
|
35
|
+
was generated for this project when it was scaffolded — keep it out of version
|
|
36
|
+
control, and use a different one per environment.
|
|
37
|
+
|
|
38
|
+
## Documentation
|
|
39
|
+
|
|
40
|
+
Full documentation is at [zerotal.dev/docs](https://zerotal.dev/docs).
|
|
@@ -1,4 +1,22 @@
|
|
|
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.
|
|
3
11
|
APP_URL=http://localhost:3000
|
|
12
|
+
|
|
13
|
+
# Bump to bust cached frontend assets after a deploy.
|
|
4
14
|
ASSET_VERSION=1
|
|
15
|
+
|
|
16
|
+
# Where the user table lives. Must match the driver in config/database.ts.
|
|
17
|
+
#
|
|
18
|
+
# Left commented on purpose. config/database.ts already points at this file, and
|
|
19
|
+
# `bun zt test` treats a set DATABASE_URL as the test database too — so setting
|
|
20
|
+
# it here would have the suite mutate your development data instead of running
|
|
21
|
+
# against a throwaway :memory: schema. Uncomment only to point somewhere else.
|
|
22
|
+
# 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
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { BaseModel, column, table } from "zerotal/orm";
|
|
2
|
+
|
|
3
|
+
@table("users")
|
|
4
|
+
export class User extends BaseModel {
|
|
5
|
+
// Models guard every attribute by default. Only these may be mass-assigned
|
|
6
|
+
// from a request body — `role` is deliberately absent, so no amount of extra
|
|
7
|
+
// fields in a form post can promote the account that submitted it.
|
|
8
|
+
static override fillable = ["name", "email", "password"];
|
|
9
|
+
|
|
10
|
+
// Never serialised into a rendered page or a JSON response.
|
|
11
|
+
static override hidden = ["password"];
|
|
12
|
+
|
|
13
|
+
@column({ type: "string" }) name!: string;
|
|
14
|
+
@column({ type: "string" }) email!: string;
|
|
15
|
+
@column({ type: "string" }) password!: string;
|
|
16
|
+
@column({ type: "string", nullable: true, default: "user" }) role?: string;
|
|
17
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { HttpContext } from "zerotal";
|
|
2
|
+
import { Inertia } from "@zerotal/inertia";
|
|
3
|
+
import { GuestMiddleware } from "zerotal/auth";
|
|
4
|
+
import { validate } from "zerotal/validator";
|
|
5
|
+
import { passwordReset } from "@app/auth/passwords";
|
|
6
|
+
|
|
7
|
+
export const middleware = [GuestMiddleware];
|
|
8
|
+
|
|
9
|
+
export const GET = async () => {
|
|
10
|
+
return Inertia.render("forgot-password", { title: "Reset your password" });
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export async function POST(http: HttpContext): Promise<void> {
|
|
14
|
+
const { email } = await validate(http, (r) => ({
|
|
15
|
+
email: r.string().trim().email(),
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
await passwordReset.sendResetLink(email);
|
|
19
|
+
|
|
20
|
+
// The same answer whether or not that address has an account — reporting "no
|
|
21
|
+
// such user" would make this form a way to enumerate registered addresses.
|
|
22
|
+
http.flash("success", "If that address has an account, a reset link is on its way.");
|
|
23
|
+
http.redirect("/forgot-password", 303);
|
|
24
|
+
}
|