@shaferllc/keel 0.82.0 → 0.83.1
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/README.md +2 -2
- package/dist/billing/billable.d.ts +2 -3
- package/dist/billing/billable.js +2 -3
- package/dist/billing/billing.config.stub +1 -1
- package/dist/billing/builder.d.ts +2 -2
- package/dist/billing/builder.js +2 -2
- package/dist/billing/provider.d.ts +1 -1
- package/dist/billing/provider.js +2 -2
- package/dist/billing/subscription-item.d.ts +0 -1
- package/dist/billing/subscription-item.js +0 -1
- package/dist/core/decorators.d.ts +5 -7
- package/dist/core/decorators.js +5 -7
- package/dist/core/exceptions.d.ts +2 -3
- package/dist/core/exceptions.js +2 -3
- package/dist/core/model-query.d.ts +1 -1
- package/dist/core/model-query.js +1 -1
- package/dist/core/request-logger.d.ts +2 -2
- package/dist/core/request-logger.js +2 -2
- package/dist/core/request.js +1 -1
- package/dist/core/testing.d.ts +3 -3
- package/dist/core/testing.js +3 -3
- 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 +23 -2
- package/docs/billing.md +5 -6
- package/docs/changelog.md +106 -21
- package/docs/database.md +5 -522
- package/docs/decorators.md +2 -6
- package/docs/errors.md +1 -1
- package/docs/hooks.md +1 -3
- package/docs/logger.md +2 -2
- package/docs/models.md +5 -2
- package/docs/orm.md +57 -0
- package/docs/packages.md +3 -3
- package/docs/providers.md +6 -6
- package/docs/query-builder.md +533 -0
- package/docs/request-response.md +2 -2
- package/docs/starter-kits.md +88 -0
- package/docs/testing.md +1 -2
- package/docs/validation.md +1 -2
- package/llms-full.txt +5301 -5126
- package/llms.txt +4 -1
- 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,15 @@
|
|
|
1
|
+
import { Model } from "@shaferllc/keel/core";
|
|
2
|
+
|
|
3
|
+
export class User extends Model {
|
|
4
|
+
static override table = "users";
|
|
5
|
+
static override fillable = ["name", "email", "password"];
|
|
6
|
+
// Never serialize these — `hidden` is a denylist on toJSON().
|
|
7
|
+
static override hidden = ["password", "two_factor_secret", "two_factor_recovery_codes"];
|
|
8
|
+
static override timestamps = true;
|
|
9
|
+
|
|
10
|
+
declare id: number;
|
|
11
|
+
declare name: string;
|
|
12
|
+
declare email: string;
|
|
13
|
+
declare password: string;
|
|
14
|
+
declare email_verified_at: string | null;
|
|
15
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { ServiceProvider, setUserProvider } from "@shaferllc/keel/core";
|
|
2
|
+
|
|
3
|
+
import { User } from "../Models/User.js";
|
|
4
|
+
|
|
5
|
+
export class AppServiceProvider extends ServiceProvider {
|
|
6
|
+
register(): void {}
|
|
7
|
+
|
|
8
|
+
boot(): void {
|
|
9
|
+
// How `auth().user()` loads the signed-in user from the id in the session.
|
|
10
|
+
setUserProvider((id) => User.find(Number(id)));
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -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
|
+
}
|