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,101 @@
|
|
|
1
|
+
import type { FormEvent, ReactNode } from "react";
|
|
2
|
+
import { Head, Link, useForm, usePage } from "@inertiajs/react";
|
|
3
|
+
import AppLayout from "../Layouts/AppLayout";
|
|
4
|
+
import { Button } from "../Components/Button";
|
|
5
|
+
import { Card } from "../Components/Card";
|
|
6
|
+
import { TextField } from "../Components/Field";
|
|
7
|
+
import type { SharedProps } from "../types";
|
|
8
|
+
|
|
9
|
+
interface Props {
|
|
10
|
+
title: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function Register({ title }: Props) {
|
|
14
|
+
const { old } = usePage<SharedProps>().props;
|
|
15
|
+
|
|
16
|
+
const { data, setData, post, processing, errors } = useForm({
|
|
17
|
+
name: typeof old["name"] === "string" ? old["name"] : "",
|
|
18
|
+
email: typeof old["email"] === "string" ? old["email"] : "",
|
|
19
|
+
password: "",
|
|
20
|
+
password_confirmation: "",
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
function submit(event: FormEvent) {
|
|
24
|
+
event.preventDefault();
|
|
25
|
+
post("/register");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<>
|
|
30
|
+
<Head title={title} />
|
|
31
|
+
|
|
32
|
+
<div className="mx-auto max-w-md">
|
|
33
|
+
<header>
|
|
34
|
+
<h1 className="text-3xl font-bold tracking-tight text-balance">{title}</h1>
|
|
35
|
+
<p className="mt-2 text-muted-foreground">It takes about ten seconds.</p>
|
|
36
|
+
</header>
|
|
37
|
+
|
|
38
|
+
<Card className="mt-8 p-6 sm:p-8">
|
|
39
|
+
<form onSubmit={submit} className="space-y-5">
|
|
40
|
+
<TextField
|
|
41
|
+
label="Name"
|
|
42
|
+
autoComplete="name"
|
|
43
|
+
value={data.name}
|
|
44
|
+
error={errors.name}
|
|
45
|
+
onChange={(e) => setData("name", e.target.value)}
|
|
46
|
+
required
|
|
47
|
+
/>
|
|
48
|
+
|
|
49
|
+
<TextField
|
|
50
|
+
label="Email"
|
|
51
|
+
type="email"
|
|
52
|
+
autoComplete="email"
|
|
53
|
+
value={data.email}
|
|
54
|
+
error={errors.email}
|
|
55
|
+
onChange={(e) => setData("email", e.target.value)}
|
|
56
|
+
required
|
|
57
|
+
/>
|
|
58
|
+
|
|
59
|
+
<TextField
|
|
60
|
+
label="Password"
|
|
61
|
+
type="password"
|
|
62
|
+
autoComplete="new-password"
|
|
63
|
+
hint="At least 8 characters."
|
|
64
|
+
value={data.password}
|
|
65
|
+
error={errors.password}
|
|
66
|
+
onChange={(e) => setData("password", e.target.value)}
|
|
67
|
+
required
|
|
68
|
+
/>
|
|
69
|
+
|
|
70
|
+
<TextField
|
|
71
|
+
label="Confirm password"
|
|
72
|
+
type="password"
|
|
73
|
+
autoComplete="new-password"
|
|
74
|
+
value={data.password_confirmation}
|
|
75
|
+
error={errors.password_confirmation}
|
|
76
|
+
onChange={(e) => setData("password_confirmation", e.target.value)}
|
|
77
|
+
required
|
|
78
|
+
/>
|
|
79
|
+
|
|
80
|
+
<Button type="submit" disabled={processing}>
|
|
81
|
+
{processing ? "Creating…" : "Create account"}
|
|
82
|
+
</Button>
|
|
83
|
+
</form>
|
|
84
|
+
</Card>
|
|
85
|
+
|
|
86
|
+
<p className="mt-6 text-sm text-muted-foreground">
|
|
87
|
+
Already registered?{" "}
|
|
88
|
+
<Link href="/login" className="text-accent hover:underline">
|
|
89
|
+
Sign in
|
|
90
|
+
</Link>
|
|
91
|
+
</p>
|
|
92
|
+
</div>
|
|
93
|
+
</>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
(Register as { layout?: (page: ReactNode) => ReactNode }).layout = (page) => (
|
|
98
|
+
<AppLayout>{page}</AppLayout>
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
export default Register;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { FormEvent, ReactNode } from "react";
|
|
2
|
+
import { Head, useForm } from "@inertiajs/react";
|
|
3
|
+
import AppLayout from "../Layouts/AppLayout";
|
|
4
|
+
import { Button } from "../Components/Button";
|
|
5
|
+
import { Card } from "../Components/Card";
|
|
6
|
+
import { TextField } from "../Components/Field";
|
|
7
|
+
|
|
8
|
+
interface Props {
|
|
9
|
+
title: string;
|
|
10
|
+
/** Both arrive on the query string of the emailed link. */
|
|
11
|
+
token: string;
|
|
12
|
+
email: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function ResetPassword({ title, token, email }: Props) {
|
|
16
|
+
// Token and email ride along as hidden fields so the POST carries everything
|
|
17
|
+
// the server needs to verify the link without trusting the session.
|
|
18
|
+
const { data, setData, post, processing, errors } = useForm({
|
|
19
|
+
token,
|
|
20
|
+
email,
|
|
21
|
+
password: "",
|
|
22
|
+
password_confirmation: "",
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
function submit(event: FormEvent) {
|
|
26
|
+
event.preventDefault();
|
|
27
|
+
post("/reset-password");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<>
|
|
32
|
+
<Head title={title} />
|
|
33
|
+
|
|
34
|
+
<div className="mx-auto max-w-md">
|
|
35
|
+
<header>
|
|
36
|
+
<h1 className="text-3xl font-bold tracking-tight text-balance">{title}</h1>
|
|
37
|
+
<p className="mt-2 text-muted-foreground">Resetting the password for {email}.</p>
|
|
38
|
+
</header>
|
|
39
|
+
|
|
40
|
+
<Card className="mt-8 p-6 sm:p-8">
|
|
41
|
+
<form onSubmit={submit} className="space-y-5">
|
|
42
|
+
<input type="hidden" value={data.token} />
|
|
43
|
+
<input type="hidden" value={data.email} />
|
|
44
|
+
|
|
45
|
+
<TextField
|
|
46
|
+
label="New password"
|
|
47
|
+
type="password"
|
|
48
|
+
autoComplete="new-password"
|
|
49
|
+
hint="At least 8 characters."
|
|
50
|
+
value={data.password}
|
|
51
|
+
error={errors.password}
|
|
52
|
+
onChange={(e) => setData("password", e.target.value)}
|
|
53
|
+
required
|
|
54
|
+
/>
|
|
55
|
+
|
|
56
|
+
<TextField
|
|
57
|
+
label="Confirm password"
|
|
58
|
+
type="password"
|
|
59
|
+
autoComplete="new-password"
|
|
60
|
+
value={data.password_confirmation}
|
|
61
|
+
error={errors.password_confirmation}
|
|
62
|
+
onChange={(e) => setData("password_confirmation", e.target.value)}
|
|
63
|
+
required
|
|
64
|
+
/>
|
|
65
|
+
|
|
66
|
+
<Button type="submit" disabled={processing}>
|
|
67
|
+
{processing ? "Updating…" : "Update password"}
|
|
68
|
+
</Button>
|
|
69
|
+
</form>
|
|
70
|
+
</Card>
|
|
71
|
+
</div>
|
|
72
|
+
</>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
(ResetPassword as { layout?: (page: ReactNode) => ReactNode }).layout = (page) => (
|
|
77
|
+
<AppLayout>{page}</AppLayout>
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
export default ResetPassword;
|
|
@@ -9,5 +9,10 @@ export const pages: Record<string, () => Promise<{ default: unknown }>> = {
|
|
|
9
9
|
about: () => import("./pages/about.tsx"),
|
|
10
10
|
contact: () => import("./pages/contact.tsx"),
|
|
11
11
|
error: () => import("./pages/error.tsx"),
|
|
12
|
+
"forgot-password": () => import("./pages/forgot-password.tsx"),
|
|
12
13
|
home: () => import("./pages/home.tsx"),
|
|
14
|
+
login: () => import("./pages/login.tsx"),
|
|
15
|
+
profile: () => import("./pages/profile.tsx"),
|
|
16
|
+
register: () => import("./pages/register.tsx"),
|
|
17
|
+
"reset-password": () => import("./pages/reset-password.tsx"),
|
|
13
18
|
};
|
|
@@ -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
|
|
|
@@ -15,6 +15,8 @@ beforeAll(async () => {
|
|
|
15
15
|
// Session encryption needs a key. Seed a deterministic one for tests; `??=`
|
|
16
16
|
// leaves a real key alone when the app's .env is present.
|
|
17
17
|
Bun.env.APP_KEY ??= 'test-app-key-aaaaaaaaaaaaaaaaaaaaaaaa';
|
|
18
|
+
// Each run gets its own throwaway schema rather than the dev database.
|
|
19
|
+
Bun.env.ZT_DB_URL ??= ':memory:';
|
|
18
20
|
app = await createTestApp(() => import('../bootstrap/app.ts').then((m) => m.default));
|
|
19
21
|
});
|
|
20
22
|
|
|
@@ -50,7 +52,14 @@ describe('scaffold', () => {
|
|
|
50
52
|
*/
|
|
51
53
|
describe('contact form', () => {
|
|
52
54
|
test('sends the visitor back with errors when the input is bad', async () => {
|
|
53
|
-
|
|
55
|
+
// Referer is what a browser sends on a form post, and what the framework
|
|
56
|
+
// redirects back to when validation fails — without it the redirect
|
|
57
|
+
// falls back to "/" and the assertion below would be testing nothing.
|
|
58
|
+
const res = await app.postForm(
|
|
59
|
+
'/contact',
|
|
60
|
+
{ name: 'A', email: 'nope', message: 'short' },
|
|
61
|
+
{ Referer: `${app.baseUrl}/contact` },
|
|
62
|
+
);
|
|
54
63
|
|
|
55
64
|
res.assertRedirect('/contact');
|
|
56
65
|
res.assertInvalid(['name', 'email', 'message']);
|
|
@@ -67,3 +76,50 @@ describe('contact form', () => {
|
|
|
67
76
|
res.assertValid();
|
|
68
77
|
});
|
|
69
78
|
});
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The auth surface, end to end. Registration is the round trip worth asserting:
|
|
82
|
+
* a valid post creates the account, signs the visitor in, and lands them on a
|
|
83
|
+
* page a guest cannot reach — which proves the guard and the session together.
|
|
84
|
+
*/
|
|
85
|
+
describe('auth', () => {
|
|
86
|
+
test('serves the guest screens', async () => {
|
|
87
|
+
(await app.get('/login')).assertInertia('login');
|
|
88
|
+
(await app.get('/register')).assertInertia('register');
|
|
89
|
+
(await app.get('/forgot-password')).assertInertia('forgot-password');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('keeps the profile behind a sign-in', async () => {
|
|
93
|
+
(await app.get('/profile')).assertRedirect('/login');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('rejects a registration whose confirmation does not match', async () => {
|
|
97
|
+
const res = await app.postForm(
|
|
98
|
+
'/register',
|
|
99
|
+
{
|
|
100
|
+
name: 'Ada Lovelace',
|
|
101
|
+
email: 'mismatch@example.com',
|
|
102
|
+
password: 'correct-horse-battery',
|
|
103
|
+
password_confirmation: 'something-else',
|
|
104
|
+
},
|
|
105
|
+
{ Referer: `${app.baseUrl}/register` },
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
res.assertRedirect('/register');
|
|
109
|
+
res.assertInvalid(['password']);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('registers a visitor and signs them in', async () => {
|
|
113
|
+
const res = await app.postForm('/register', {
|
|
114
|
+
name: 'Ada Lovelace',
|
|
115
|
+
email: 'ada@example.com',
|
|
116
|
+
password: 'correct-horse-battery',
|
|
117
|
+
password_confirmation: 'correct-horse-battery',
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// Landing on /profile is the proof: it is a guarded route, so reaching it
|
|
121
|
+
// means the account was created *and* the session was established.
|
|
122
|
+
res.assertRedirect('/profile');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
});
|
|
@@ -13,9 +13,16 @@
|
|
|
13
13
|
"jsxImportSource": "react",
|
|
14
14
|
"skipLibCheck": true,
|
|
15
15
|
"paths": {
|
|
16
|
-
"@app/*": [
|
|
17
|
-
|
|
16
|
+
"@app/*": [
|
|
17
|
+
"./app/*"
|
|
18
|
+
],
|
|
19
|
+
"@pages/*": [
|
|
20
|
+
"./resources/js/pages/*"
|
|
21
|
+
]
|
|
18
22
|
}
|
|
19
23
|
},
|
|
20
|
-
"exclude": [
|
|
24
|
+
"exclude": [
|
|
25
|
+
"node_modules",
|
|
26
|
+
".zerotal"
|
|
27
|
+
]
|
|
21
28
|
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# {{name}}
|
|
2
|
+
|
|
3
|
+
An Inertia + Vue 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,55 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ExceptionHandler,
|
|
3
|
+
ZerotalError,
|
|
4
|
+
devSurfacesEnabled,
|
|
5
|
+
type HttpContext,
|
|
6
|
+
} from "zerotal";
|
|
7
|
+
import { inertia } from "@zerotal/inertia";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Renders HTTP errors as a page inside the app's own shell.
|
|
11
|
+
*
|
|
12
|
+
* Registered in bootstrap/app.ts. Without it, a mistyped URL lands on the
|
|
13
|
+
* framework's plain error page — correct, but jarring after the rest of the app.
|
|
14
|
+
* This hands the status to resources/js/pages/error.tsx instead.
|
|
15
|
+
*
|
|
16
|
+
* Three cases deliberately fall through to `super.render()`:
|
|
17
|
+
*
|
|
18
|
+
* - **A 5xx in development.** The framework's stack-trace page is far more
|
|
19
|
+
* useful than a tidy "something broke", so it keeps priority while dev
|
|
20
|
+
* surfaces are on. Client errors (404, 403, …) always render the app's page,
|
|
21
|
+
* including in development — otherwise you could never see your own.
|
|
22
|
+
* - **Inertia XHR.** A client-side visit expects the adapter's own error
|
|
23
|
+
* handling; substituting a page object here would break it.
|
|
24
|
+
* - **API clients.** Anything asking for JSON keeps getting JSON.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** Statuses this app renders as a page; everything else uses the default. */
|
|
28
|
+
const RENDERED = new Set([403, 404, 419, 429, 500, 503]);
|
|
29
|
+
|
|
30
|
+
function statusOf(error: unknown): number {
|
|
31
|
+
return error instanceof ZerotalError ? error.status : 500;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export class Handler extends ExceptionHandler {
|
|
35
|
+
override async render(error: unknown, ctx: HttpContext): Promise<Response> {
|
|
36
|
+
const status = statusOf(error);
|
|
37
|
+
const wantsHtml = (ctx.request.headers.get("Accept") ?? "").includes("text/html");
|
|
38
|
+
const isInertiaVisit = ctx.request.headers.get("X-Inertia") === "true";
|
|
39
|
+
|
|
40
|
+
if (isInertiaVisit || !wantsHtml || !RENDERED.has(status)) {
|
|
41
|
+
return super.render(error, ctx);
|
|
42
|
+
}
|
|
43
|
+
if (status >= 500 && devSurfacesEnabled()) {
|
|
44
|
+
return super.render(error, ctx);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// `inertia()` writes the HTML onto the context rather than returning it, so
|
|
48
|
+
// re-wrap the body to carry the real status instead of a 200.
|
|
49
|
+
await inertia("error", { status });
|
|
50
|
+
const rendered = ctx.response;
|
|
51
|
+
if (!rendered) return super.render(error, ctx);
|
|
52
|
+
|
|
53
|
+
return new Response(rendered.body, { status, headers: rendered.headers });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { HttpContext } from "zerotal";
|
|
2
|
+
import { Inertia } from "@zerotal/inertia";
|
|
3
|
+
import { Auth, GuestMiddleware } from "zerotal/auth";
|
|
4
|
+
import { validate } from "zerotal/validator";
|
|
5
|
+
|
|
6
|
+
// Signed-in visitors are sent away from the sign-in form rather than shown it.
|
|
7
|
+
export const middleware = [GuestMiddleware];
|
|
8
|
+
|
|
9
|
+
export const GET = async () => {
|
|
10
|
+
return Inertia.render("login", { title: "Sign in" });
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export async function POST(http: HttpContext): Promise<void> {
|
|
14
|
+
const { email, password, remember } = await validate(http, (r) => ({
|
|
15
|
+
email: r.string().trim().email(),
|
|
16
|
+
password: r.string().min(1),
|
|
17
|
+
remember: r.boolean().optional(),
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
const ok = await Auth.attempt({ email, password }, Boolean(remember));
|
|
21
|
+
|
|
22
|
+
if (!ok) {
|
|
23
|
+
// One message for a wrong address and a wrong password alike. Saying which
|
|
24
|
+
// was wrong would turn this form into a way to discover valid accounts.
|
|
25
|
+
http.flash("error", "Those credentials do not match our records.");
|
|
26
|
+
http.redirect("/login", 303);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
http.flash("success", "Welcome back.");
|
|
31
|
+
http.redirect("/profile", 303);
|
|
32
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { HttpContext } from "zerotal";
|
|
2
|
+
import { Auth, AuthMiddleware } from "zerotal/auth";
|
|
3
|
+
|
|
4
|
+
export const middleware = [AuthMiddleware];
|
|
5
|
+
|
|
6
|
+
// POST rather than GET: a link that signs you out can be triggered by any page
|
|
7
|
+
// that manages to make your browser fetch it.
|
|
8
|
+
export async function POST(http: HttpContext): Promise<void> {
|
|
9
|
+
await Auth.logout();
|
|
10
|
+
http.flash("success", "Signed out.");
|
|
11
|
+
http.redirect("/login", 303);
|
|
12
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { HttpContext } from "zerotal";
|
|
2
|
+
import { Auth, AuthMiddleware, Hash } from "zerotal/auth";
|
|
3
|
+
import { validate } from "zerotal/validator";
|
|
4
|
+
import { User } from "@app/models/User";
|
|
5
|
+
|
|
6
|
+
export const middleware = [AuthMiddleware];
|
|
7
|
+
|
|
8
|
+
// POST /profile/password
|
|
9
|
+
export async function POST(http: HttpContext): Promise<void> {
|
|
10
|
+
const user = Auth.user() as User;
|
|
11
|
+
|
|
12
|
+
const { current_password, password } = await validate(http, (r) => ({
|
|
13
|
+
current_password: r.string().min(1),
|
|
14
|
+
password: r.string().min(8).confirmed(),
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
// Proving the current password is what stops a borrowed session from locking
|
|
18
|
+
// the real owner out of their own account.
|
|
19
|
+
if (!(await Hash.check(current_password, user.password))) {
|
|
20
|
+
http.flash("error", "Your current password is not correct.");
|
|
21
|
+
http.redirect("/profile", 303);
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
user.password = await Hash.make(password);
|
|
26
|
+
await user.save();
|
|
27
|
+
|
|
28
|
+
http.flash("success", "Password updated.");
|
|
29
|
+
http.redirect("/profile", 303);
|
|
30
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { HttpContext } from "zerotal";
|
|
2
|
+
import { Inertia } from "@zerotal/inertia";
|
|
3
|
+
import { Auth, AuthMiddleware } from "zerotal/auth";
|
|
4
|
+
import { validate } from "zerotal/validator";
|
|
5
|
+
import { User } from "@app/models/User";
|
|
6
|
+
|
|
7
|
+
// Only for signed-in visitors; everyone else is redirected to sign in.
|
|
8
|
+
export const middleware = [AuthMiddleware];
|
|
9
|
+
|
|
10
|
+
export const GET = async () => {
|
|
11
|
+
return Inertia.render("profile", { title: "Your profile" });
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
// Updates name and email. Changing the password is its own route, so a failure
|
|
15
|
+
// in one form never discards what was typed in the other.
|
|
16
|
+
export async function POST(http: HttpContext): Promise<void> {
|
|
17
|
+
const user = Auth.user() as User;
|
|
18
|
+
|
|
19
|
+
const { name, email } = await validate(http, (r) => ({
|
|
20
|
+
name: r.string().trim().min(2).max(80),
|
|
21
|
+
email: r.string().trim().email(),
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
// Someone else holding this address is the only conflict — keeping your own
|
|
25
|
+
// is not a change at all.
|
|
26
|
+
const taken = await User.query().where("email", email).first();
|
|
27
|
+
if (taken && taken.id !== user.id) {
|
|
28
|
+
http.flash("error", "That email is already in use.");
|
|
29
|
+
http.redirect("/profile", 303);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
user.name = name;
|
|
34
|
+
user.email = email;
|
|
35
|
+
await user.save();
|
|
36
|
+
|
|
37
|
+
http.flash("success", "Details saved.");
|
|
38
|
+
http.redirect("/profile", 303);
|
|
39
|
+
}
|