@shaferllc/keel 0.82.0 → 0.83.0
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/dist/db/d1-http.d.ts +34 -0
- package/dist/db/d1-http.js +61 -0
- package/dist/db/libsql.d.ts +13 -3
- package/dist/teams/models.js +51 -5
- package/docs/ai-manifest.json +22 -1
- package/docs/database.md +5 -522
- package/docs/models.md +5 -2
- package/docs/orm.md +57 -0
- package/docs/query-builder.md +533 -0
- package/docs/starter-kits.md +88 -0
- package/llms-full.txt +5299 -5115
- package/llms.txt +3 -0
- package/package.json +7 -2
- package/templates/api/.env.example +12 -0
- package/templates/api/app/Controllers/PostController.ts +37 -0
- package/templates/api/app/Http/Kernel.ts +13 -0
- package/templates/api/app/Http/Middleware/requestLogger.ts +8 -0
- package/templates/api/app/Models/Post.ts +12 -0
- package/templates/api/app/Providers/AppServiceProvider.ts +8 -0
- package/templates/api/app/Providers/DatabaseServiceProvider.ts +52 -0
- package/templates/api/bin/keel.ts +15 -0
- package/templates/api/bootstrap/app.ts +24 -0
- package/templates/api/bootstrap/providers.edge.ts +14 -0
- package/templates/api/bootstrap/providers.ts +7 -0
- package/templates/api/config/app.ts +10 -0
- package/templates/api/config/database.ts +42 -0
- package/templates/api/database/migrations/0001_create_posts.ts +20 -0
- package/templates/api/package.json +30 -0
- package/templates/api/routes/web.ts +12 -0
- package/templates/api/tests/posts.test.ts +30 -0
- package/templates/api/tsconfig.json +16 -0
- package/templates/api/worker.ts +33 -0
- package/templates/api/wrangler.jsonc +22 -0
- package/templates/app/.env.example +12 -0
- package/templates/app/app/Controllers/AuthController.ts +117 -0
- package/templates/app/app/Controllers/DashboardController.ts +37 -0
- package/templates/app/app/Controllers/HomeController.ts +10 -0
- package/templates/app/app/Http/Kernel.ts +21 -0
- package/templates/app/app/Http/Middleware/requestLogger.ts +8 -0
- package/templates/app/app/Models/User.ts +15 -0
- package/templates/app/app/Providers/AppServiceProvider.ts +12 -0
- package/templates/app/app/Providers/DatabaseServiceProvider.ts +52 -0
- package/templates/app/bin/keel.ts +15 -0
- package/templates/app/bootstrap/app.ts +24 -0
- package/templates/app/bootstrap/providers.edge.ts +15 -0
- package/templates/app/bootstrap/providers.ts +18 -0
- package/templates/app/config/app.ts +10 -0
- package/templates/app/config/database.ts +42 -0
- package/templates/app/database/migrations/0001_create_users.ts +21 -0
- package/templates/app/package.json +35 -0
- package/templates/app/public/.gitkeep +2 -0
- package/templates/app/resources/css/app.css +1 -0
- package/templates/app/resources/views/auth/forgot.tsx +30 -0
- package/templates/app/resources/views/auth/login.tsx +24 -0
- package/templates/app/resources/views/auth/register.tsx +24 -0
- package/templates/app/resources/views/auth/two-factor.tsx +22 -0
- package/templates/app/resources/views/dashboard.tsx +20 -0
- package/templates/app/resources/views/layout.tsx +15 -0
- package/templates/app/resources/views/welcome.tsx +29 -0
- package/templates/app/routes/web.ts +35 -0
- package/templates/app/tests/auth.test.ts +47 -0
- package/templates/app/tsconfig.json +31 -0
- package/templates/app/worker.ts +33 -0
- package/templates/app/wrangler.jsonc +25 -0
- package/templates/minimal/.env.example +8 -0
- package/templates/minimal/app/Controllers/HomeController.ts +14 -0
- package/templates/minimal/app/Http/Kernel.ts +22 -0
- package/templates/minimal/app/Http/Middleware/requestLogger.ts +8 -0
- package/templates/minimal/app/Providers/AppServiceProvider.ts +8 -0
- package/templates/minimal/bin/keel.ts +15 -0
- package/templates/minimal/bootstrap/app.ts +24 -0
- package/templates/minimal/bootstrap/providers.ts +6 -0
- package/templates/minimal/config/app.ts +10 -0
- package/templates/minimal/package.json +29 -0
- package/templates/minimal/public/.gitkeep +2 -0
- package/templates/minimal/resources/css/app.css +1 -0
- package/templates/minimal/resources/views/layout.tsx +15 -0
- package/templates/minimal/resources/views/welcome.tsx +15 -0
- package/templates/minimal/routes/web.ts +13 -0
- package/templates/minimal/tsconfig.json +18 -0
- package/templates/minimal/worker.ts +23 -0
- package/templates/minimal/wrangler.jsonc +10 -0
- package/templates/saas/.env.example +12 -0
- package/templates/saas/app/Controllers/AuthController.ts +125 -0
- package/templates/saas/app/Controllers/DashboardController.ts +37 -0
- package/templates/saas/app/Controllers/HomeController.ts +10 -0
- package/templates/saas/app/Controllers/TeamController.ts +88 -0
- package/templates/saas/app/Http/Kernel.ts +27 -0
- package/templates/saas/app/Http/Middleware/requestLogger.ts +8 -0
- package/templates/saas/app/Models/Project.ts +20 -0
- package/templates/saas/app/Models/User.ts +15 -0
- package/templates/saas/app/Providers/AppServiceProvider.ts +12 -0
- package/templates/saas/app/Providers/DatabaseServiceProvider.ts +52 -0
- package/templates/saas/bin/keel.ts +15 -0
- package/templates/saas/bootstrap/app.ts +24 -0
- package/templates/saas/bootstrap/providers.edge.ts +18 -0
- package/templates/saas/bootstrap/providers.ts +22 -0
- package/templates/saas/config/app.ts +10 -0
- package/templates/saas/config/database.ts +42 -0
- package/templates/saas/database/migrations/0001_create_users.ts +21 -0
- package/templates/saas/database/migrations/0002_create_projects.ts +20 -0
- package/templates/saas/package.json +35 -0
- package/templates/saas/public/.gitkeep +2 -0
- package/templates/saas/resources/css/app.css +1 -0
- package/templates/saas/resources/views/auth/forgot.tsx +30 -0
- package/templates/saas/resources/views/auth/login.tsx +24 -0
- package/templates/saas/resources/views/auth/register.tsx +24 -0
- package/templates/saas/resources/views/auth/two-factor.tsx +22 -0
- package/templates/saas/resources/views/dashboard.tsx +20 -0
- package/templates/saas/resources/views/layout.tsx +15 -0
- package/templates/saas/resources/views/teams/index.tsx +85 -0
- package/templates/saas/resources/views/welcome.tsx +29 -0
- package/templates/saas/routes/web.ts +44 -0
- package/templates/saas/tests/auth.test.ts +47 -0
- package/templates/saas/tests/teams.test.ts +27 -0
- package/templates/saas/tsconfig.json +31 -0
- package/templates/saas/worker.ts +33 -0
- package/templates/saas/wrangler.jsonc +25 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { ServiceProvider, config, setConnection } from "@shaferllc/keel/core";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Opens the connection every model and query builder reads through — on **Node**.
|
|
5
|
+
* (In the Worker, worker.ts binds D1 directly and this provider isn't loaded.)
|
|
6
|
+
*
|
|
7
|
+
* Switching database is `DB_CONNECTION` and nothing else: no model or query changes,
|
|
8
|
+
* because they all talk to a `Connection` rather than to a driver.
|
|
9
|
+
*
|
|
10
|
+
* `d1` here means the HTTP API rather than the binding — which is what lets
|
|
11
|
+
* `keel migrate` reach your real D1 database from your laptop and from CI, where no
|
|
12
|
+
* binding exists.
|
|
13
|
+
*/
|
|
14
|
+
export class DatabaseServiceProvider extends ServiceProvider {
|
|
15
|
+
async register(): Promise<void> {
|
|
16
|
+
const name = config<string>("database.default", "sqlite");
|
|
17
|
+
|
|
18
|
+
if (name === "d1") {
|
|
19
|
+
const { d1HttpConnection } = await import("@shaferllc/keel/db/d1-http");
|
|
20
|
+
|
|
21
|
+
setConnection(
|
|
22
|
+
d1HttpConnection({
|
|
23
|
+
accountId: config<string>("database.connections.d1.accountId", ""),
|
|
24
|
+
databaseId: config<string>("database.connections.d1.databaseId", ""),
|
|
25
|
+
apiToken: config<string>("database.connections.d1.apiToken", ""),
|
|
26
|
+
}),
|
|
27
|
+
"sqlite",
|
|
28
|
+
);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (name === "postgres") {
|
|
33
|
+
const { pgConnection } = await import("@shaferllc/keel/db/pg");
|
|
34
|
+
const { Pool } = await import("pg");
|
|
35
|
+
|
|
36
|
+
setConnection(
|
|
37
|
+
pgConnection(new Pool({ connectionString: config<string>("database.connections.postgres.url", "") })),
|
|
38
|
+
"postgres",
|
|
39
|
+
);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// sqlite (a local file) or turso (libSQL over the network) — same driver.
|
|
44
|
+
const { libsqlConnection } = await import("@shaferllc/keel/db/libsql");
|
|
45
|
+
const { createClient } = await import("@libsql/client");
|
|
46
|
+
|
|
47
|
+
const url = config<string>(`database.connections.${name}.url`, "file:./database.sqlite");
|
|
48
|
+
const authToken = config<string>(`database.connections.${name}.authToken`, "");
|
|
49
|
+
|
|
50
|
+
setConnection(libsqlConnection(createClient(authToken ? { url, authToken } : { url })), "sqlite");
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* The application console. `npm run keel <command>`.
|
|
4
|
+
*
|
|
5
|
+
* The commands — serve, routes, repl, migrate:*, every make:* — come from the
|
|
6
|
+
* framework. This file only says how to build *your* application.
|
|
7
|
+
*/
|
|
8
|
+
import { run } from "@shaferllc/keel/cli";
|
|
9
|
+
|
|
10
|
+
import { createApplication } from "../bootstrap/app.js";
|
|
11
|
+
|
|
12
|
+
run(process.argv, { createApplication }).catch((error) => {
|
|
13
|
+
console.error(error);
|
|
14
|
+
process.exit(1);
|
|
15
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Application bootstrap. Creates the container, boots providers, loads routes.
|
|
3
|
+
* The server, the console, and the Worker all enter through here.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Application, Router, HttpKernel, type ProviderClass } from "@shaferllc/keel/core";
|
|
7
|
+
|
|
8
|
+
import { providers as nodeProviders } from "./providers.js";
|
|
9
|
+
import { Kernel } from "../app/Http/Kernel.js";
|
|
10
|
+
import registerWebRoutes from "../routes/web.js";
|
|
11
|
+
|
|
12
|
+
export async function createApplication(providers: ProviderClass[] = nodeProviders): Promise<Application> {
|
|
13
|
+
const app = new Application(process.cwd());
|
|
14
|
+
|
|
15
|
+
// Binding our Kernel under HttpKernel is what makes `keel serve` use it. Without
|
|
16
|
+
// this the console falls back to a bare HttpKernel and the global middleware
|
|
17
|
+
// quietly vanishes.
|
|
18
|
+
app.singleton(HttpKernel, (container) => new Kernel(container as Application));
|
|
19
|
+
|
|
20
|
+
await app.boot(providers);
|
|
21
|
+
registerWebRoutes(app.make(Router));
|
|
22
|
+
|
|
23
|
+
return app;
|
|
24
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ProviderClass } from "@shaferllc/keel/core";
|
|
2
|
+
import { AccountsServiceProvider } from "@shaferllc/keel/accounts";
|
|
3
|
+
import { TeamsServiceProvider } from "@shaferllc/keel/teams";
|
|
4
|
+
import { BillingServiceProvider } from "@shaferllc/keel/billing";
|
|
5
|
+
|
|
6
|
+
import { AppServiceProvider } from "../app/Providers/AppServiceProvider.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Providers for the Worker. `DatabaseServiceProvider` is deliberately absent — it
|
|
10
|
+
* reaches for `pg` (net/tls), which cannot be bundled for the edge. worker.ts binds
|
|
11
|
+
* D1 before boot.
|
|
12
|
+
*/
|
|
13
|
+
export const edgeProviders: ProviderClass[] = [
|
|
14
|
+
AccountsServiceProvider,
|
|
15
|
+
TeamsServiceProvider,
|
|
16
|
+
BillingServiceProvider,
|
|
17
|
+
AppServiceProvider,
|
|
18
|
+
];
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { ProviderClass } from "@shaferllc/keel/core";
|
|
2
|
+
import { AccountsServiceProvider } from "@shaferllc/keel/accounts";
|
|
3
|
+
import { TeamsServiceProvider } from "@shaferllc/keel/teams";
|
|
4
|
+
import { BillingServiceProvider } from "@shaferllc/keel/billing";
|
|
5
|
+
|
|
6
|
+
import { AppServiceProvider } from "../app/Providers/AppServiceProvider.js";
|
|
7
|
+
import { DatabaseServiceProvider } from "../app/Providers/DatabaseServiceProvider.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Providers for the Node runtime.
|
|
11
|
+
*
|
|
12
|
+
* Teams and billing know nothing about each other. Billing parameterizes what it
|
|
13
|
+
* charges (`billableModel`), so *this app* points it at teams — the team is the
|
|
14
|
+
* customer. That seam is what keeps them from becoming one tangled module.
|
|
15
|
+
*/
|
|
16
|
+
export const providers: ProviderClass[] = [
|
|
17
|
+
DatabaseServiceProvider,
|
|
18
|
+
AccountsServiceProvider,
|
|
19
|
+
TeamsServiceProvider,
|
|
20
|
+
BillingServiceProvider,
|
|
21
|
+
AppServiceProvider,
|
|
22
|
+
];
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { env } from "@shaferllc/keel/core";
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
name: env("APP_NAME", "Keel App"),
|
|
5
|
+
env: env("APP_ENV", "local"),
|
|
6
|
+
debug: env("APP_DEBUG", true),
|
|
7
|
+
url: env("APP_URL", "http://localhost:3000"),
|
|
8
|
+
port: env("APP_PORT", 3000),
|
|
9
|
+
key: env("APP_KEY", ""),
|
|
10
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { env } from "@shaferllc/keel/core";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Every driver, out of the box. `DB_CONNECTION` picks one.
|
|
5
|
+
*
|
|
6
|
+
* The default is `d1` in production — Cloudflare is where this is meant to run —
|
|
7
|
+
* and `sqlite` locally, so `npm run dev` needs no wrangler and no account. Both are
|
|
8
|
+
* SQLite, so one schema and one set of migrations serve both.
|
|
9
|
+
*/
|
|
10
|
+
export default {
|
|
11
|
+
default: env("DB_CONNECTION", "sqlite"),
|
|
12
|
+
|
|
13
|
+
connections: {
|
|
14
|
+
// Cloudflare D1. Inside the Worker the binding (env.DB) is used directly;
|
|
15
|
+
// migrations and scripts reach the same database over the HTTP API.
|
|
16
|
+
d1: {
|
|
17
|
+
driver: "d1",
|
|
18
|
+
binding: "DB",
|
|
19
|
+
accountId: env("CLOUDFLARE_ACCOUNT_ID", ""),
|
|
20
|
+
databaseId: env("D1_DATABASE_ID", ""),
|
|
21
|
+
apiToken: env("CLOUDFLARE_API_TOKEN", ""),
|
|
22
|
+
},
|
|
23
|
+
|
|
24
|
+
// A local file. Zero setup — this is what `npm run dev` uses.
|
|
25
|
+
sqlite: {
|
|
26
|
+
driver: "libsql",
|
|
27
|
+
url: env("DB_URL", "file:./database.sqlite"),
|
|
28
|
+
},
|
|
29
|
+
|
|
30
|
+
// libSQL over the network (Turso).
|
|
31
|
+
turso: {
|
|
32
|
+
driver: "libsql",
|
|
33
|
+
url: env("TURSO_URL", ""),
|
|
34
|
+
authToken: env("TURSO_AUTH_TOKEN", ""),
|
|
35
|
+
},
|
|
36
|
+
|
|
37
|
+
postgres: {
|
|
38
|
+
driver: "pg",
|
|
39
|
+
url: env("DATABASE_URL", ""),
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Migration } from "@shaferllc/keel/core";
|
|
2
|
+
|
|
3
|
+
const migration: Migration = {
|
|
4
|
+
name: "0001_create_users",
|
|
5
|
+
|
|
6
|
+
async up(schema) {
|
|
7
|
+
await schema.createTable("users", (t) => {
|
|
8
|
+
t.id();
|
|
9
|
+
t.string("name");
|
|
10
|
+
t.string("email").unique();
|
|
11
|
+
t.string("password");
|
|
12
|
+
t.timestamps();
|
|
13
|
+
});
|
|
14
|
+
},
|
|
15
|
+
|
|
16
|
+
async down(schema) {
|
|
17
|
+
await schema.dropTable("users");
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export default migration;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { Migration } from "@shaferllc/keel/core";
|
|
2
|
+
|
|
3
|
+
const migration: Migration = {
|
|
4
|
+
name: "0002_create_projects",
|
|
5
|
+
|
|
6
|
+
async up(schema) {
|
|
7
|
+
await schema.createTable("projects", (t) => {
|
|
8
|
+
t.id();
|
|
9
|
+
t.integer("team_id");
|
|
10
|
+
t.string("name");
|
|
11
|
+
t.timestamps();
|
|
12
|
+
});
|
|
13
|
+
},
|
|
14
|
+
|
|
15
|
+
async down(schema) {
|
|
16
|
+
await schema.dropTable("projects");
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export default migration;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "__APP_NAME__",
|
|
3
|
+
"private": true,
|
|
4
|
+
"type": "module",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"keel": "tsx bin/keel.ts",
|
|
7
|
+
"dev": "npm run css:build && concurrently -k -n css,web \"npm run css:watch\" \"tsx watch bin/keel.ts serve\"",
|
|
8
|
+
"serve": "tsx bin/keel.ts serve",
|
|
9
|
+
"dev:edge": "wrangler dev",
|
|
10
|
+
"deploy": "npm run css:build && wrangler deploy",
|
|
11
|
+
"migrate": "tsx bin/keel.ts migrate",
|
|
12
|
+
"css:build": "tailwindcss -i ./resources/css/app.css -o ./public/assets/app.css --minify",
|
|
13
|
+
"css:watch": "tailwindcss -i ./resources/css/app.css -o ./public/assets/app.css --watch",
|
|
14
|
+
"test": "tsx --test --test-concurrency=1 tests/*.test.ts",
|
|
15
|
+
"typecheck": "tsc --noEmit"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"@hono/node-server": "^1.13.7",
|
|
19
|
+
"@libsql/client": "^0.14.0",
|
|
20
|
+
"@shaferllc/keel": "__KEEL_VERSION__",
|
|
21
|
+
"hono": "^4.6.14",
|
|
22
|
+
"zod": "^3.24.1",
|
|
23
|
+
"pg": "^8.13.1"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@types/node": "^22.10.0",
|
|
27
|
+
"tsx": "^4.19.2",
|
|
28
|
+
"typescript": "^5.7.2",
|
|
29
|
+
"wrangler": "^4.0.0",
|
|
30
|
+
"@types/pg": "^8.11.10",
|
|
31
|
+
"@tailwindcss/cli": "^4.0.0",
|
|
32
|
+
"tailwindcss": "^4.0.0",
|
|
33
|
+
"concurrently": "^9.1.0"
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
@import "tailwindcss";
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import Layout from "../layout.js";
|
|
2
|
+
|
|
3
|
+
export default function Forgot({ sent }: { sent: boolean }) {
|
|
4
|
+
return (
|
|
5
|
+
<Layout title="Reset your password">
|
|
6
|
+
<main class="mx-auto flex min-h-screen max-w-sm flex-col justify-center gap-6 px-6">
|
|
7
|
+
<h1 class="text-2xl font-semibold tracking-tight">Reset your password</h1>
|
|
8
|
+
|
|
9
|
+
{sent ? (
|
|
10
|
+
// The same answer whether or not that address has an account — otherwise
|
|
11
|
+
// this page tells a stranger which emails are registered.
|
|
12
|
+
<p class="rounded-lg bg-slate-100 px-3 py-2 text-sm">
|
|
13
|
+
If that address has an account, a link is on its way.
|
|
14
|
+
</p>
|
|
15
|
+
) : (
|
|
16
|
+
<form method="post" action="/forgot-password" class="flex flex-col gap-3">
|
|
17
|
+
<input
|
|
18
|
+
class="rounded-lg border border-slate-300 px-3 py-2"
|
|
19
|
+
type="email"
|
|
20
|
+
name="email"
|
|
21
|
+
placeholder="Email"
|
|
22
|
+
required
|
|
23
|
+
/>
|
|
24
|
+
<button class="rounded-lg bg-slate-900 px-4 py-2 text-white">Send the link</button>
|
|
25
|
+
</form>
|
|
26
|
+
)}
|
|
27
|
+
</main>
|
|
28
|
+
</Layout>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import Layout from "../layout.js";
|
|
2
|
+
|
|
3
|
+
export default function Login({ error }: { error: string | null }) {
|
|
4
|
+
return (
|
|
5
|
+
<Layout title="Log in">
|
|
6
|
+
<main class="mx-auto flex min-h-screen max-w-sm flex-col justify-center gap-6 px-6">
|
|
7
|
+
<h1 class="text-2xl font-semibold tracking-tight">Log in</h1>
|
|
8
|
+
|
|
9
|
+
{error && <p class="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">{error}</p>}
|
|
10
|
+
|
|
11
|
+
<form method="post" action="/login" class="flex flex-col gap-3">
|
|
12
|
+
<input class="rounded-lg border border-slate-300 px-3 py-2" type="email" name="email" placeholder="Email" required />
|
|
13
|
+
<input class="rounded-lg border border-slate-300 px-3 py-2" type="password" name="password" placeholder="Password" required />
|
|
14
|
+
<button class="rounded-lg bg-slate-900 px-4 py-2 text-white">Log in</button>
|
|
15
|
+
</form>
|
|
16
|
+
|
|
17
|
+
<p class="text-sm text-slate-500">
|
|
18
|
+
<a class="underline" href="/register">Register</a> ·{" "}
|
|
19
|
+
<a class="underline" href="/forgot-password">Forgot password?</a>
|
|
20
|
+
</p>
|
|
21
|
+
</main>
|
|
22
|
+
</Layout>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import Layout from "../layout.js";
|
|
2
|
+
|
|
3
|
+
export default function Register({ error }: { error: string | null }) {
|
|
4
|
+
return (
|
|
5
|
+
<Layout title="Register">
|
|
6
|
+
<main class="mx-auto flex min-h-screen max-w-sm flex-col justify-center gap-6 px-6">
|
|
7
|
+
<h1 class="text-2xl font-semibold tracking-tight">Register</h1>
|
|
8
|
+
|
|
9
|
+
{error && <p class="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">{error}</p>}
|
|
10
|
+
|
|
11
|
+
<form method="post" action="/register" class="flex flex-col gap-3">
|
|
12
|
+
<input class="rounded-lg border border-slate-300 px-3 py-2" name="name" placeholder="Name" required />
|
|
13
|
+
<input class="rounded-lg border border-slate-300 px-3 py-2" type="email" name="email" placeholder="Email" required />
|
|
14
|
+
<input class="rounded-lg border border-slate-300 px-3 py-2" type="password" name="password" placeholder="Password" required />
|
|
15
|
+
<button class="rounded-lg bg-slate-900 px-4 py-2 text-white">Register</button>
|
|
16
|
+
</form>
|
|
17
|
+
|
|
18
|
+
<p class="text-sm text-slate-500">
|
|
19
|
+
Already have an account? <a class="underline" href="/login">Log in</a>
|
|
20
|
+
</p>
|
|
21
|
+
</main>
|
|
22
|
+
</Layout>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import Layout from "../layout.js";
|
|
2
|
+
|
|
3
|
+
export default function TwoFactor({ error }: { error: string | null }) {
|
|
4
|
+
return (
|
|
5
|
+
<Layout title="Two-factor">
|
|
6
|
+
<main class="mx-auto flex min-h-screen max-w-sm flex-col justify-center gap-6 px-6">
|
|
7
|
+
<h1 class="text-2xl font-semibold tracking-tight">Two-factor</h1>
|
|
8
|
+
|
|
9
|
+
{error && <p class="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">{error}</p>}
|
|
10
|
+
|
|
11
|
+
<form method="post" action="/two-factor" class="flex flex-col gap-3">
|
|
12
|
+
<input class="rounded-lg border border-slate-300 px-3 py-2" name="code" placeholder="6-digit code, or a recovery code" required autofocus />
|
|
13
|
+
<button class="rounded-lg bg-slate-900 px-4 py-2 text-white">Two-factor</button>
|
|
14
|
+
</form>
|
|
15
|
+
|
|
16
|
+
<p class="text-sm text-slate-500">
|
|
17
|
+
Your password was accepted. You are not signed in until this code checks out.
|
|
18
|
+
</p>
|
|
19
|
+
</main>
|
|
20
|
+
</Layout>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import Layout from "./layout.js";
|
|
2
|
+
|
|
3
|
+
export default function Dashboard({ name, twoFactor }: { name: string; twoFactor: boolean }) {
|
|
4
|
+
return (
|
|
5
|
+
<Layout title="Dashboard">
|
|
6
|
+
<main class="mx-auto max-w-2xl px-6 py-16">
|
|
7
|
+
<h1 class="text-3xl font-semibold tracking-tight">Hello, {name}.</h1>
|
|
8
|
+
|
|
9
|
+
<p class="mt-4 text-slate-600">
|
|
10
|
+
Two-factor is {twoFactor ? "on" : "off"}.{" "}
|
|
11
|
+
{!twoFactor && "POST /two-factor/enable to start setting it up."}
|
|
12
|
+
</p>
|
|
13
|
+
|
|
14
|
+
<form method="post" action="/logout" class="mt-8">
|
|
15
|
+
<button class="rounded-lg border border-slate-300 px-4 py-2">Log out</button>
|
|
16
|
+
</form>
|
|
17
|
+
</main>
|
|
18
|
+
</Layout>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { PropsWithChildren } from "hono/jsx";
|
|
2
|
+
|
|
3
|
+
export default function Layout({ title, children }: PropsWithChildren<{ title: string }>) {
|
|
4
|
+
return (
|
|
5
|
+
<html lang="en">
|
|
6
|
+
<head>
|
|
7
|
+
<meta charset="utf-8" />
|
|
8
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
9
|
+
<title>{title}</title>
|
|
10
|
+
<link rel="stylesheet" href="/assets/app.css" />
|
|
11
|
+
</head>
|
|
12
|
+
<body class="min-h-screen bg-slate-50 text-slate-900 antialiased">{children}</body>
|
|
13
|
+
</html>
|
|
14
|
+
);
|
|
15
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import Layout from "../layout.js";
|
|
2
|
+
|
|
3
|
+
interface Props {
|
|
4
|
+
teams: { id: number; name: string }[];
|
|
5
|
+
current: number | null;
|
|
6
|
+
projects: { id: number; name: string }[];
|
|
7
|
+
invitations: { id: number; email: string; role: string }[];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export default function Teams({ teams, current, projects, invitations }: Props) {
|
|
11
|
+
return (
|
|
12
|
+
<Layout title="Teams">
|
|
13
|
+
<main class="mx-auto max-w-2xl px-6 py-16">
|
|
14
|
+
<h1 class="text-3xl font-semibold tracking-tight">Teams</h1>
|
|
15
|
+
|
|
16
|
+
<section class="mt-8">
|
|
17
|
+
<h2 class="text-sm font-medium uppercase tracking-wide text-slate-500">Your teams</h2>
|
|
18
|
+
|
|
19
|
+
<ul class="mt-3 flex flex-col gap-2">
|
|
20
|
+
{teams.map((team) => (
|
|
21
|
+
<li class="flex items-center justify-between rounded-lg border border-slate-200 bg-white px-4 py-3">
|
|
22
|
+
<span>
|
|
23
|
+
{team.name} {team.id === current && <em class="text-slate-500">— current</em>}
|
|
24
|
+
</span>
|
|
25
|
+
|
|
26
|
+
{team.id !== current && (
|
|
27
|
+
<form method="post" action="/teams/switch">
|
|
28
|
+
<input type="hidden" name="team_id" value={String(team.id)} />
|
|
29
|
+
<button class="text-sm underline">Switch</button>
|
|
30
|
+
</form>
|
|
31
|
+
)}
|
|
32
|
+
</li>
|
|
33
|
+
))}
|
|
34
|
+
</ul>
|
|
35
|
+
|
|
36
|
+
<form method="post" action="/teams" class="mt-3 flex gap-2">
|
|
37
|
+
<input class="flex-1 rounded-lg border border-slate-300 px-3 py-2" name="name" placeholder="New team name" required />
|
|
38
|
+
<button class="rounded-lg bg-slate-900 px-4 py-2 text-white">Create</button>
|
|
39
|
+
</form>
|
|
40
|
+
</section>
|
|
41
|
+
|
|
42
|
+
<section class="mt-10">
|
|
43
|
+
<h2 class="text-sm font-medium uppercase tracking-wide text-slate-500">
|
|
44
|
+
Projects in this team
|
|
45
|
+
</h2>
|
|
46
|
+
<p class="mt-1 text-sm text-slate-500">
|
|
47
|
+
Scoped automatically — another team's project isn't just hidden, it's a 404.
|
|
48
|
+
</p>
|
|
49
|
+
|
|
50
|
+
<ul class="mt-3 flex flex-col gap-2">
|
|
51
|
+
{projects.map((project) => (
|
|
52
|
+
<li class="rounded-lg border border-slate-200 bg-white px-4 py-3">{project.name}</li>
|
|
53
|
+
))}
|
|
54
|
+
</ul>
|
|
55
|
+
|
|
56
|
+
<form method="post" action="/projects" class="mt-3 flex gap-2">
|
|
57
|
+
<input class="flex-1 rounded-lg border border-slate-300 px-3 py-2" name="name" placeholder="New project" required />
|
|
58
|
+
<button class="rounded-lg bg-slate-900 px-4 py-2 text-white">Add</button>
|
|
59
|
+
</form>
|
|
60
|
+
</section>
|
|
61
|
+
|
|
62
|
+
<section class="mt-10">
|
|
63
|
+
<h2 class="text-sm font-medium uppercase tracking-wide text-slate-500">Invitations</h2>
|
|
64
|
+
|
|
65
|
+
<ul class="mt-3 flex flex-col gap-2">
|
|
66
|
+
{invitations.map((invitation) => (
|
|
67
|
+
<li class="rounded-lg border border-slate-200 bg-white px-4 py-3 text-sm">
|
|
68
|
+
{invitation.email} · {invitation.role}
|
|
69
|
+
</li>
|
|
70
|
+
))}
|
|
71
|
+
</ul>
|
|
72
|
+
|
|
73
|
+
<form method="post" action="/teams/invite" class="mt-3 flex gap-2">
|
|
74
|
+
<input class="flex-1 rounded-lg border border-slate-300 px-3 py-2" type="email" name="email" placeholder="Email" required />
|
|
75
|
+
<select class="rounded-lg border border-slate-300 px-3 py-2" name="role">
|
|
76
|
+
<option value="member">Member</option>
|
|
77
|
+
<option value="admin">Admin</option>
|
|
78
|
+
</select>
|
|
79
|
+
<button class="rounded-lg bg-slate-900 px-4 py-2 text-white">Invite</button>
|
|
80
|
+
</form>
|
|
81
|
+
</section>
|
|
82
|
+
</main>
|
|
83
|
+
</Layout>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import Layout from "./layout.js";
|
|
2
|
+
|
|
3
|
+
export default function Welcome({ signedIn }: { signedIn: boolean }) {
|
|
4
|
+
return (
|
|
5
|
+
<Layout title="Keel">
|
|
6
|
+
<main class="mx-auto flex min-h-screen max-w-2xl flex-col justify-center gap-6 px-6">
|
|
7
|
+
<h1 class="text-4xl font-semibold tracking-tight">Keel</h1>
|
|
8
|
+
<p class="text-slate-600">Sessions, password reset, and two-factor are already wired.</p>
|
|
9
|
+
|
|
10
|
+
<div class="flex gap-3">
|
|
11
|
+
{signedIn ? (
|
|
12
|
+
<a class="rounded-lg bg-slate-900 px-4 py-2 text-white" href="/dashboard">
|
|
13
|
+
Dashboard
|
|
14
|
+
</a>
|
|
15
|
+
) : (
|
|
16
|
+
<>
|
|
17
|
+
<a class="rounded-lg bg-slate-900 px-4 py-2 text-white" href="/login">
|
|
18
|
+
Log in
|
|
19
|
+
</a>
|
|
20
|
+
<a class="rounded-lg border border-slate-300 px-4 py-2" href="/register">
|
|
21
|
+
Register
|
|
22
|
+
</a>
|
|
23
|
+
</>
|
|
24
|
+
)}
|
|
25
|
+
</div>
|
|
26
|
+
</main>
|
|
27
|
+
</Layout>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { Router, Ctx } from "@shaferllc/keel/core";
|
|
2
|
+
import { auth } from "@shaferllc/keel/core";
|
|
3
|
+
|
|
4
|
+
import { AuthController } from "../app/Controllers/AuthController.js";
|
|
5
|
+
import { TeamController } from "../app/Controllers/TeamController.js";
|
|
6
|
+
import { DashboardController } from "../app/Controllers/DashboardController.js";
|
|
7
|
+
import { HomeController } from "../app/Controllers/HomeController.js";
|
|
8
|
+
|
|
9
|
+
/** Send guests to the login page. */
|
|
10
|
+
const authenticated = async (c: Ctx, next: () => Promise<void>) => {
|
|
11
|
+
if (auth().guest()) return c.redirect("/login");
|
|
12
|
+
await next();
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export default function routes(router: Router): void {
|
|
16
|
+
router.get("/", [HomeController, "index"]);
|
|
17
|
+
router.get("/health", (c: Ctx) => c.json({ ok: true }));
|
|
18
|
+
|
|
19
|
+
router.get("/login", [AuthController, "showLogin"]).name("login");
|
|
20
|
+
router.post("/login", [AuthController, "login"]);
|
|
21
|
+
|
|
22
|
+
router.get("/two-factor", [AuthController, "showTwoFactor"]);
|
|
23
|
+
router.post("/two-factor", [AuthController, "twoFactor"]);
|
|
24
|
+
|
|
25
|
+
router.get("/register", [AuthController, "showRegister"]).name("register");
|
|
26
|
+
router.post("/register", [AuthController, "register"]);
|
|
27
|
+
|
|
28
|
+
router.post("/logout", [AuthController, "logout"]).name("logout");
|
|
29
|
+
|
|
30
|
+
router.get("/forgot-password", [AuthController, "showForgot"]);
|
|
31
|
+
router.post("/forgot-password", [AuthController, "forgot"]);
|
|
32
|
+
router.post("/reset-password", [AuthController, "reset"]);
|
|
33
|
+
|
|
34
|
+
router.get("/dashboard", [DashboardController, "index"]).middleware(authenticated).name("dashboard");
|
|
35
|
+
|
|
36
|
+
router.get("/teams", [TeamController, "index"]).middleware(authenticated).name("teams");
|
|
37
|
+
router.post("/teams", [TeamController, "store"]).middleware(authenticated);
|
|
38
|
+
router.post("/teams/switch", [TeamController, "switch"]).middleware(authenticated);
|
|
39
|
+
router.post("/teams/invite", [TeamController, "invite"]).middleware(authenticated);
|
|
40
|
+
router.get("/invitations/:token", [TeamController, "accept"]).middleware(authenticated);
|
|
41
|
+
|
|
42
|
+
router.post("/projects", [TeamController, "createProject"]).middleware(authenticated);
|
|
43
|
+
router.post("/two-factor/enable", [DashboardController, "startTwoFactor"]).middleware(authenticated);
|
|
44
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
|
|
4
|
+
import { HttpKernel, testClient } from "@shaferllc/keel/core";
|
|
5
|
+
|
|
6
|
+
import { createApplication } from "../bootstrap/app.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* These hit the real routes through the real kernel. A starter that ships no tests
|
|
10
|
+
* teaches that tests are optional — and auth is the last place you want that.
|
|
11
|
+
*/
|
|
12
|
+
test("a visitor can register and land in their personal team", async () => {
|
|
13
|
+
const app = await createApplication();
|
|
14
|
+
const client = testClient(app.make(HttpKernel));
|
|
15
|
+
|
|
16
|
+
(await client.get("/login")).assertOk();
|
|
17
|
+
|
|
18
|
+
const registered = await client.form("/register", {
|
|
19
|
+
name: "Ada",
|
|
20
|
+
email: `ada+${crypto.randomUUID()}@example.com`,
|
|
21
|
+
password: "correct horse battery",
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
// A redirect means the session was set and the personal team exists.
|
|
25
|
+
assert.equal(registered.status, 302);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("the dashboard turns guests away", async () => {
|
|
29
|
+
const app = await createApplication();
|
|
30
|
+
const client = testClient(app.make(HttpKernel));
|
|
31
|
+
|
|
32
|
+
const response = await client.get("/dashboard");
|
|
33
|
+
|
|
34
|
+
assert.equal(response.status, 302, "a guest is redirected, not shown the page");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("a wrong password says nothing about whether the account exists", async () => {
|
|
38
|
+
const app = await createApplication();
|
|
39
|
+
const client = testClient(app.make(HttpKernel));
|
|
40
|
+
|
|
41
|
+
const response = await client.form("/login", {
|
|
42
|
+
email: "nobody@example.com",
|
|
43
|
+
password: "wrong",
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
assert.equal(response.status, 401);
|
|
47
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
|
|
4
|
+
import { HttpKernel, testClient } from "@shaferllc/keel/core";
|
|
5
|
+
|
|
6
|
+
import { createApplication } from "../bootstrap/app.js";
|
|
7
|
+
|
|
8
|
+
test("registering creates a personal team and lands on it", async () => {
|
|
9
|
+
const app = await createApplication();
|
|
10
|
+
const client = testClient(app.make(HttpKernel));
|
|
11
|
+
|
|
12
|
+
const registered = await client.form("/register", {
|
|
13
|
+
name: "Ada",
|
|
14
|
+
email: `ada+${crypto.randomUUID()}@example.com`,
|
|
15
|
+
password: "correct horse battery",
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
// Redirected to /teams — the personal team exists, so tenant queries can run.
|
|
19
|
+
assert.equal(registered.status, 302);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("teams turn guests away", async () => {
|
|
23
|
+
const app = await createApplication();
|
|
24
|
+
const client = testClient(app.make(HttpKernel));
|
|
25
|
+
|
|
26
|
+
assert.equal((await client.get("/teams")).status, 302);
|
|
27
|
+
});
|