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,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
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { HttpContext } from "zerotal";
|
|
2
|
+
import { Inertia } from "@zerotal/inertia";
|
|
3
|
+
import { Auth, GuestMiddleware, Hash } from "zerotal/auth";
|
|
4
|
+
import { validate } from "zerotal/validator";
|
|
5
|
+
import { User } from "@app/models/User";
|
|
6
|
+
|
|
7
|
+
export const middleware = [GuestMiddleware];
|
|
8
|
+
|
|
9
|
+
export const GET = async () => {
|
|
10
|
+
return Inertia.render("register", { title: "Create an account" });
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export async function POST(http: HttpContext): Promise<void> {
|
|
14
|
+
const { name, email, password } = await validate(http, (r) => ({
|
|
15
|
+
name: r.string().trim().min(2).max(80),
|
|
16
|
+
email: r.string().trim().email(),
|
|
17
|
+
password: r.string().min(8).confirmed(),
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
const taken = await User.query().where("email", email).first();
|
|
21
|
+
if (taken) {
|
|
22
|
+
http.flash("error", "That email is already registered.");
|
|
23
|
+
http.redirect("/register", 303);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// `role` is not fillable, so it cannot arrive from the request at all — it is
|
|
28
|
+
// set here, in code, which is the whole point of leaving it off `fillable`.
|
|
29
|
+
const user = new User();
|
|
30
|
+
user.name = name;
|
|
31
|
+
user.email = email;
|
|
32
|
+
user.password = await Hash.make(password);
|
|
33
|
+
user.role = "user";
|
|
34
|
+
await user.save();
|
|
35
|
+
|
|
36
|
+
await Auth.attempt({ email, password });
|
|
37
|
+
http.flash("success", "Welcome aboard.");
|
|
38
|
+
http.redirect("/profile", 303);
|
|
39
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
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
|
+
// The token and address arrive on the query string of the emailed link and are
|
|
10
|
+
// handed to the page, which posts them back with the new password.
|
|
11
|
+
export const GET = async (http: HttpContext) => {
|
|
12
|
+
const url = new URL(http.request.url);
|
|
13
|
+
return Inertia.render("reset-password", {
|
|
14
|
+
title: "Choose a new password",
|
|
15
|
+
token: url.searchParams.get("token") ?? "",
|
|
16
|
+
email: url.searchParams.get("email") ?? "",
|
|
17
|
+
});
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export async function POST(http: HttpContext): Promise<void> {
|
|
21
|
+
const { email, token, password } = await validate(http, (r) => ({
|
|
22
|
+
email: r.string().trim().email(),
|
|
23
|
+
token: r.string().min(1),
|
|
24
|
+
password: r.string().min(8).confirmed(),
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
const ok = await passwordReset.reset({ email, token, password });
|
|
28
|
+
|
|
29
|
+
if (!ok) {
|
|
30
|
+
http.flash("error", "This reset link is invalid or has expired.");
|
|
31
|
+
http.redirect(`/reset-password?token=${token}&email=${encodeURIComponent(email)}`, 303);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
http.flash("success", "Your password has been updated. Sign in with it below.");
|
|
36
|
+
http.redirect("/login", 303);
|
|
37
|
+
}
|
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import { Application, basePath } from "zerotal";
|
|
2
2
|
import providers from "./providers";
|
|
3
3
|
import { Handler } from "../app/exceptions/Handler";
|
|
4
|
+
import type { User } from "../app/models/User.ts";
|
|
5
|
+
|
|
6
|
+
// Tell @zerotal/auth which model `Auth.user()` returns. The empty body is the
|
|
7
|
+
// point: this augmentation exists to bind the type, not to add members.
|
|
8
|
+
declare module "zerotal/auth" {
|
|
9
|
+
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
|
10
|
+
interface UserModel extends User {}
|
|
11
|
+
}
|
|
4
12
|
|
|
5
13
|
const app = Application.create({ providers })
|
|
6
14
|
.fileBasedRouting({
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { LogProvider } from "zerotal/logger";
|
|
2
|
+
import { DatabaseProvider } from "zerotal/orm";
|
|
2
3
|
import { SessionProvider } from "zerotal/session";
|
|
4
|
+
import { AuthProvider } from "zerotal/auth";
|
|
3
5
|
import { InertiaProvider } from "@zerotal/inertia";
|
|
4
6
|
|
|
5
|
-
// Order matters
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
|
|
7
|
+
// Order matters: the database backs the user model and the session underpins
|
|
8
|
+
// auth, so both are registered before AuthProvider boots. AuthProvider installs
|
|
9
|
+
// the middleware that puts the signed-in user on the request — which is what
|
|
10
|
+
// makes `auth.user` appear in every Inertia page's props.
|
|
11
|
+
const providers = [LogProvider, DatabaseProvider, SessionProvider, AuthProvider, InertiaProvider];
|
|
9
12
|
|
|
10
13
|
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,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
|
+
}
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
"dev": "bun zt.ts serve --dev",
|
|
9
9
|
"start": "bun zt.ts serve",
|
|
10
10
|
"build": "bun zt.ts inertia:build --production",
|
|
11
|
-
"test": "bun zt.ts test"
|
|
11
|
+
"test": "bun zt.ts test",
|
|
12
|
+
"typecheck": "tsc --noEmit"
|
|
12
13
|
},
|
|
13
14
|
"dependencies": {
|
|
14
15
|
"@inertiajs/react": "^2.0.0",
|
|
@@ -21,6 +22,11 @@
|
|
|
21
22
|
"@types/react": "^19.0.0",
|
|
22
23
|
"@types/react-dom": "^19.0.0",
|
|
23
24
|
"bun-plugin-tailwind": "latest",
|
|
24
|
-
"tailwindcss": "^4.0.0"
|
|
25
|
+
"tailwindcss": "^4.0.0",
|
|
26
|
+
"typescript": "^5.8.0",
|
|
27
|
+
"@types/bun": "^1.3.14"
|
|
28
|
+
},
|
|
29
|
+
"engines": {
|
|
30
|
+
"bun": ">=1.3.14"
|
|
25
31
|
}
|
|
26
32
|
}
|
|
@@ -5,6 +5,7 @@ import ThemeToggle from "../Components/ThemeToggle";
|
|
|
5
5
|
import { CloseIcon, MenuIcon } from "../Components/Icons";
|
|
6
6
|
import { APP_NAME, DOCS_URL } from "../lib/site";
|
|
7
7
|
import { cn } from "../lib/cn";
|
|
8
|
+
import type { SharedProps } from "../types";
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Shared application shell — decorative backdrop, sticky header with
|
|
@@ -26,6 +27,14 @@ const NAV = [
|
|
|
26
27
|
{ href: "/contact", label: "Contact" },
|
|
27
28
|
];
|
|
28
29
|
|
|
30
|
+
// Shown to signed-in visitors and guests respectively, so the header always
|
|
31
|
+
// offers the next useful step rather than a link that would bounce them.
|
|
32
|
+
const AUTHED_NAV = [{ href: "/profile", label: "Profile" }];
|
|
33
|
+
const GUEST_NAV = [
|
|
34
|
+
{ href: "/login", label: "Sign in" },
|
|
35
|
+
{ href: "/register", label: "Register" },
|
|
36
|
+
];
|
|
37
|
+
|
|
29
38
|
const CONTAINER = "mx-auto w-full max-w-5xl px-6";
|
|
30
39
|
|
|
31
40
|
function isActive(url: string, href: string): boolean {
|
|
@@ -33,7 +42,8 @@ function isActive(url: string, href: string): boolean {
|
|
|
33
42
|
}
|
|
34
43
|
|
|
35
44
|
export default function AppLayout({ children }: { children: ReactNode }) {
|
|
36
|
-
const { url } = usePage();
|
|
45
|
+
const { url, props } = usePage<SharedProps>();
|
|
46
|
+
const signedIn = Boolean(props.auth?.user);
|
|
37
47
|
const [menuOpen, setMenuOpen] = useState(false);
|
|
38
48
|
|
|
39
49
|
// Close the mobile menu once a visit lands, otherwise it stays open on top of
|
|
@@ -48,7 +58,7 @@ export default function AppLayout({ children }: { children: ReactNode }) {
|
|
|
48
58
|
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
|
49
59
|
);
|
|
50
60
|
|
|
51
|
-
const navItems = NAV.map((item) => (
|
|
61
|
+
const navItems = [...NAV, ...(signedIn ? AUTHED_NAV : GUEST_NAV)].map((item) => (
|
|
52
62
|
<Link
|
|
53
63
|
key={item.href}
|
|
54
64
|
href={item.href}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { FormEvent, ReactNode } from "react";
|
|
2
|
+
import { Head, Link, 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
|
+
}
|
|
11
|
+
|
|
12
|
+
function ForgotPassword({ title }: Props) {
|
|
13
|
+
const { data, setData, post, processing, errors } = useForm({ email: "" });
|
|
14
|
+
|
|
15
|
+
function submit(event: FormEvent) {
|
|
16
|
+
event.preventDefault();
|
|
17
|
+
post("/forgot-password");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
<>
|
|
22
|
+
<Head title={title} />
|
|
23
|
+
|
|
24
|
+
<div className="mx-auto max-w-md">
|
|
25
|
+
<header>
|
|
26
|
+
<h1 className="text-3xl font-bold tracking-tight text-balance">{title}</h1>
|
|
27
|
+
<p className="mt-2 text-muted-foreground">
|
|
28
|
+
Give us the address you signed up with and we will send a link.
|
|
29
|
+
</p>
|
|
30
|
+
</header>
|
|
31
|
+
|
|
32
|
+
<Card className="mt-8 p-6 sm:p-8">
|
|
33
|
+
<form onSubmit={submit} className="space-y-5">
|
|
34
|
+
<TextField
|
|
35
|
+
label="Email"
|
|
36
|
+
type="email"
|
|
37
|
+
autoComplete="email"
|
|
38
|
+
hint="In development the link is written to the server log rather than emailed."
|
|
39
|
+
value={data.email}
|
|
40
|
+
error={errors.email}
|
|
41
|
+
onChange={(e) => setData("email", e.target.value)}
|
|
42
|
+
required
|
|
43
|
+
/>
|
|
44
|
+
|
|
45
|
+
<Button type="submit" disabled={processing}>
|
|
46
|
+
{processing ? "Sending…" : "Send reset link"}
|
|
47
|
+
</Button>
|
|
48
|
+
</form>
|
|
49
|
+
</Card>
|
|
50
|
+
|
|
51
|
+
<p className="mt-6 text-sm text-muted-foreground">
|
|
52
|
+
<Link href="/login" className="text-accent hover:underline">
|
|
53
|
+
Back to sign in
|
|
54
|
+
</Link>
|
|
55
|
+
</p>
|
|
56
|
+
</div>
|
|
57
|
+
</>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
(ForgotPassword as { layout?: (page: ReactNode) => ReactNode }).layout = (page) => (
|
|
62
|
+
<AppLayout>{page}</AppLayout>
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
export default ForgotPassword;
|
|
@@ -0,0 +1,92 @@
|
|
|
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 Login({ title }: Props) {
|
|
14
|
+
const { old } = usePage<SharedProps>().props;
|
|
15
|
+
|
|
16
|
+
const { data, setData, post, processing, errors } = useForm({
|
|
17
|
+
email: typeof old["email"] === "string" ? old["email"] : "",
|
|
18
|
+
password: "",
|
|
19
|
+
remember: false,
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
function submit(event: FormEvent) {
|
|
23
|
+
event.preventDefault();
|
|
24
|
+
post("/login");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<>
|
|
29
|
+
<Head title={title} />
|
|
30
|
+
|
|
31
|
+
<div className="mx-auto max-w-md">
|
|
32
|
+
<header>
|
|
33
|
+
<h1 className="text-3xl font-bold tracking-tight text-balance">{title}</h1>
|
|
34
|
+
<p className="mt-2 text-muted-foreground">Enter your details to continue.</p>
|
|
35
|
+
</header>
|
|
36
|
+
|
|
37
|
+
<Card className="mt-8 p-6 sm:p-8">
|
|
38
|
+
<form onSubmit={submit} className="space-y-5">
|
|
39
|
+
<TextField
|
|
40
|
+
label="Email"
|
|
41
|
+
type="email"
|
|
42
|
+
autoComplete="email"
|
|
43
|
+
value={data.email}
|
|
44
|
+
error={errors.email}
|
|
45
|
+
onChange={(e) => setData("email", e.target.value)}
|
|
46
|
+
required
|
|
47
|
+
/>
|
|
48
|
+
|
|
49
|
+
<TextField
|
|
50
|
+
label="Password"
|
|
51
|
+
type="password"
|
|
52
|
+
autoComplete="current-password"
|
|
53
|
+
value={data.password}
|
|
54
|
+
error={errors.password}
|
|
55
|
+
onChange={(e) => setData("password", e.target.value)}
|
|
56
|
+
required
|
|
57
|
+
/>
|
|
58
|
+
|
|
59
|
+
<label className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
60
|
+
<input
|
|
61
|
+
type="checkbox"
|
|
62
|
+
className="rounded border-input"
|
|
63
|
+
checked={data.remember}
|
|
64
|
+
onChange={(e) => setData("remember", e.target.checked)}
|
|
65
|
+
/>
|
|
66
|
+
Remember me
|
|
67
|
+
</label>
|
|
68
|
+
|
|
69
|
+
<Button type="submit" disabled={processing}>
|
|
70
|
+
{processing ? "Signing in…" : "Sign in"}
|
|
71
|
+
</Button>
|
|
72
|
+
</form>
|
|
73
|
+
</Card>
|
|
74
|
+
|
|
75
|
+
<div className="mt-6 flex items-center justify-between text-sm">
|
|
76
|
+
<Link href="/forgot-password" className="text-accent hover:underline">
|
|
77
|
+
Forgot your password?
|
|
78
|
+
</Link>
|
|
79
|
+
<Link href="/register" className="text-accent hover:underline">
|
|
80
|
+
Create an account
|
|
81
|
+
</Link>
|
|
82
|
+
</div>
|
|
83
|
+
</div>
|
|
84
|
+
</>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
(Login as { layout?: (page: ReactNode) => ReactNode }).layout = (page) => (
|
|
89
|
+
<AppLayout>{page}</AppLayout>
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
export default Login;
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import type { FormEvent, ReactNode } from "react";
|
|
2
|
+
import { Head, router, 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 Profile({ title }: Props) {
|
|
14
|
+
// `auth.user` is shared into every page by @zerotal/inertia, so this route
|
|
15
|
+
// does not have to pass the signed-in user as a prop.
|
|
16
|
+
const { auth } = usePage<SharedProps>().props;
|
|
17
|
+
|
|
18
|
+
const details = useForm({
|
|
19
|
+
name: auth.user?.name ?? "",
|
|
20
|
+
email: auth.user?.email ?? "",
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const password = useForm({
|
|
24
|
+
current_password: "",
|
|
25
|
+
password: "",
|
|
26
|
+
password_confirmation: "",
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
function saveDetails(event: FormEvent) {
|
|
30
|
+
event.preventDefault();
|
|
31
|
+
details.post("/profile", { preserveScroll: true });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function changePassword(event: FormEvent) {
|
|
35
|
+
event.preventDefault();
|
|
36
|
+
// Clear the fields whichever way it goes — a failed attempt should not
|
|
37
|
+
// leave the old password sitting in the DOM.
|
|
38
|
+
password.post("/profile/password", {
|
|
39
|
+
preserveScroll: true,
|
|
40
|
+
onFinish: () => password.reset(),
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<>
|
|
46
|
+
<Head title={title} />
|
|
47
|
+
|
|
48
|
+
<div className="mx-auto max-w-2xl space-y-6">
|
|
49
|
+
<header>
|
|
50
|
+
<h1 className="text-3xl font-bold tracking-tight text-balance">{title}</h1>
|
|
51
|
+
<p className="mt-2 text-muted-foreground">
|
|
52
|
+
Update your details or change your password.
|
|
53
|
+
</p>
|
|
54
|
+
</header>
|
|
55
|
+
|
|
56
|
+
<Card className="p-6 sm:p-8">
|
|
57
|
+
<h2 className="text-lg font-semibold">Details</h2>
|
|
58
|
+
|
|
59
|
+
<form onSubmit={saveDetails} className="mt-4 space-y-5">
|
|
60
|
+
<TextField
|
|
61
|
+
label="Name"
|
|
62
|
+
autoComplete="name"
|
|
63
|
+
value={details.data.name}
|
|
64
|
+
error={details.errors.name}
|
|
65
|
+
onChange={(e) => details.setData("name", e.target.value)}
|
|
66
|
+
required
|
|
67
|
+
/>
|
|
68
|
+
|
|
69
|
+
<TextField
|
|
70
|
+
label="Email"
|
|
71
|
+
type="email"
|
|
72
|
+
autoComplete="email"
|
|
73
|
+
value={details.data.email}
|
|
74
|
+
error={details.errors.email}
|
|
75
|
+
onChange={(e) => details.setData("email", e.target.value)}
|
|
76
|
+
required
|
|
77
|
+
/>
|
|
78
|
+
|
|
79
|
+
<Button type="submit" disabled={details.processing}>
|
|
80
|
+
{details.processing ? "Saving…" : "Save details"}
|
|
81
|
+
</Button>
|
|
82
|
+
</form>
|
|
83
|
+
</Card>
|
|
84
|
+
|
|
85
|
+
<Card className="p-6 sm:p-8">
|
|
86
|
+
<h2 className="text-lg font-semibold">Password</h2>
|
|
87
|
+
|
|
88
|
+
<form onSubmit={changePassword} className="mt-4 space-y-5">
|
|
89
|
+
<TextField
|
|
90
|
+
label="Current password"
|
|
91
|
+
type="password"
|
|
92
|
+
autoComplete="current-password"
|
|
93
|
+
value={password.data.current_password}
|
|
94
|
+
error={password.errors.current_password}
|
|
95
|
+
onChange={(e) => password.setData("current_password", e.target.value)}
|
|
96
|
+
required
|
|
97
|
+
/>
|
|
98
|
+
|
|
99
|
+
<TextField
|
|
100
|
+
label="New password"
|
|
101
|
+
type="password"
|
|
102
|
+
autoComplete="new-password"
|
|
103
|
+
hint="At least 8 characters."
|
|
104
|
+
value={password.data.password}
|
|
105
|
+
error={password.errors.password}
|
|
106
|
+
onChange={(e) => password.setData("password", e.target.value)}
|
|
107
|
+
required
|
|
108
|
+
/>
|
|
109
|
+
|
|
110
|
+
<TextField
|
|
111
|
+
label="Confirm new password"
|
|
112
|
+
type="password"
|
|
113
|
+
autoComplete="new-password"
|
|
114
|
+
value={password.data.password_confirmation}
|
|
115
|
+
error={password.errors.password_confirmation}
|
|
116
|
+
onChange={(e) => password.setData("password_confirmation", e.target.value)}
|
|
117
|
+
required
|
|
118
|
+
/>
|
|
119
|
+
|
|
120
|
+
<Button type="submit" disabled={password.processing}>
|
|
121
|
+
{password.processing ? "Updating…" : "Change password"}
|
|
122
|
+
</Button>
|
|
123
|
+
</form>
|
|
124
|
+
</Card>
|
|
125
|
+
|
|
126
|
+
<Card className="p-6 sm:p-8">
|
|
127
|
+
<h2 className="text-lg font-semibold">Session</h2>
|
|
128
|
+
<p className="mt-1 text-sm text-muted-foreground">Sign out of this browser.</p>
|
|
129
|
+
<Button
|
|
130
|
+
type="button"
|
|
131
|
+
variant="secondary"
|
|
132
|
+
className="mt-4"
|
|
133
|
+
onClick={() => router.post("/logout")}
|
|
134
|
+
>
|
|
135
|
+
Sign out
|
|
136
|
+
</Button>
|
|
137
|
+
</Card>
|
|
138
|
+
</div>
|
|
139
|
+
</>
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
(Profile as { layout?: (page: ReactNode) => ReactNode }).layout = (page) => (
|
|
144
|
+
<AppLayout>{page}</AppLayout>
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
export default Profile;
|