create-seiro 0.1.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/README.md +50 -0
- package/index.ts +36 -0
- package/package.json +26 -0
- package/template/.claude/skills/entity-ui.md +169 -0
- package/template/.claude/skills/new-entity.md +198 -0
- package/template/CLAUDE.md +49 -0
- package/template/app.ts +40 -0
- package/template/auth/server.ts +160 -0
- package/template/auth/types.ts +23 -0
- package/template/components/auth.ts +120 -0
- package/template/components/shared/modal.ts +205 -0
- package/template/components/shared/router.ts +11 -0
- package/template/compose.test.yml +18 -0
- package/template/compose.yml +18 -0
- package/template/index.html +23 -0
- package/template/init_db/01_extensions.sql +2 -0
- package/template/init_db/02_auth_tables.sql +7 -0
- package/template/init_db/03_auth_functions.sql +55 -0
- package/template/package.json +21 -0
- package/template/server.test.ts +129 -0
- package/template/server.ts +44 -0
- package/template/tsconfig.json +16 -0
- package/template/types.ts +9 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import type { Sql } from "postgres";
|
|
2
|
+
import type { Server } from "seiro";
|
|
3
|
+
import type {
|
|
4
|
+
User,
|
|
5
|
+
AuthResult,
|
|
6
|
+
AuthCommands,
|
|
7
|
+
AuthQueries,
|
|
8
|
+
AuthEvents,
|
|
9
|
+
} from "./types";
|
|
10
|
+
|
|
11
|
+
const JWT_SECRET =
|
|
12
|
+
process.env.JWT_SECRET ?? "seiro-dev-secret-change-in-production";
|
|
13
|
+
const JWT_EXPIRY = process.env.JWT_EXPIRY ?? "7d";
|
|
14
|
+
|
|
15
|
+
// DB row shape (snake_case from postgres)
|
|
16
|
+
type UserRow = { id: number; email: string; created_at: string };
|
|
17
|
+
|
|
18
|
+
function toUser(row: UserRow): User {
|
|
19
|
+
return { id: row.id, email: row.email, createdAt: row.created_at };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Simple JWT implementation using Bun's native crypto
|
|
23
|
+
async function signToken(userId: number): Promise<string> {
|
|
24
|
+
const header = { alg: "HS256", typ: "JWT" };
|
|
25
|
+
const now = Math.floor(Date.now() / 1000);
|
|
26
|
+
const expiry = parseExpiry(JWT_EXPIRY);
|
|
27
|
+
const payload = { sub: userId, iat: now, exp: now + expiry };
|
|
28
|
+
|
|
29
|
+
const encoder = new TextEncoder();
|
|
30
|
+
const headerB64 = btoa(JSON.stringify(header)).replace(/=/g, "");
|
|
31
|
+
const payloadB64 = btoa(JSON.stringify(payload)).replace(/=/g, "");
|
|
32
|
+
const data = `${headerB64}.${payloadB64}`;
|
|
33
|
+
|
|
34
|
+
const key = await crypto.subtle.importKey(
|
|
35
|
+
"raw",
|
|
36
|
+
encoder.encode(JWT_SECRET),
|
|
37
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
38
|
+
false,
|
|
39
|
+
["sign"],
|
|
40
|
+
);
|
|
41
|
+
const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(data));
|
|
42
|
+
const signatureB64 = btoa(
|
|
43
|
+
String.fromCharCode(...new Uint8Array(signature)),
|
|
44
|
+
).replace(/=/g, "");
|
|
45
|
+
|
|
46
|
+
return `${data}.${signatureB64}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function verifyToken(token: string): Promise<number | null> {
|
|
50
|
+
try {
|
|
51
|
+
const [headerB64, payloadB64, signatureB64] = token.split(".");
|
|
52
|
+
if (!headerB64 || !payloadB64 || !signatureB64) return null;
|
|
53
|
+
|
|
54
|
+
const encoder = new TextEncoder();
|
|
55
|
+
const data = `${headerB64}.${payloadB64}`;
|
|
56
|
+
|
|
57
|
+
const key = await crypto.subtle.importKey(
|
|
58
|
+
"raw",
|
|
59
|
+
encoder.encode(JWT_SECRET),
|
|
60
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
61
|
+
false,
|
|
62
|
+
["verify"],
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
const signature = Uint8Array.from(atob(signatureB64), (c) =>
|
|
66
|
+
c.charCodeAt(0),
|
|
67
|
+
);
|
|
68
|
+
const valid = await crypto.subtle.verify(
|
|
69
|
+
"HMAC",
|
|
70
|
+
key,
|
|
71
|
+
signature,
|
|
72
|
+
encoder.encode(data),
|
|
73
|
+
);
|
|
74
|
+
if (!valid) return null;
|
|
75
|
+
|
|
76
|
+
const payload = JSON.parse(atob(payloadB64));
|
|
77
|
+
if (payload.exp < Math.floor(Date.now() / 1000)) return null;
|
|
78
|
+
|
|
79
|
+
return payload.sub;
|
|
80
|
+
} catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function parseExpiry(expiry: string): number {
|
|
86
|
+
const match = expiry.match(/^(\d+)([smhd])$/);
|
|
87
|
+
if (!match || !match[1] || !match[2]) return 7 * 24 * 60 * 60; // default 7 days
|
|
88
|
+
const value = parseInt(match[1], 10);
|
|
89
|
+
const unit = match[2];
|
|
90
|
+
switch (unit) {
|
|
91
|
+
case "s":
|
|
92
|
+
return value;
|
|
93
|
+
case "m":
|
|
94
|
+
return value * 60;
|
|
95
|
+
case "h":
|
|
96
|
+
return value * 60 * 60;
|
|
97
|
+
case "d":
|
|
98
|
+
return value * 24 * 60 * 60;
|
|
99
|
+
default:
|
|
100
|
+
return 7 * 24 * 60 * 60;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function register<
|
|
105
|
+
C extends AuthCommands,
|
|
106
|
+
Q extends AuthQueries,
|
|
107
|
+
E extends AuthEvents,
|
|
108
|
+
>(server: Server<C, Q, E>, sql: Sql) {
|
|
109
|
+
// Send profile on connect
|
|
110
|
+
server.onOpen(async (ctx) => {
|
|
111
|
+
if (!ctx.userId) {
|
|
112
|
+
ctx.send({ profile: null });
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
const [row] = await sql<[{ query_auth_profile: UserRow }?]>`
|
|
116
|
+
SELECT query_auth_profile(${ctx.userId}, ${sql.json({})})
|
|
117
|
+
`;
|
|
118
|
+
if (row) {
|
|
119
|
+
ctx.send({ profile: toUser(row.query_auth_profile) });
|
|
120
|
+
} else {
|
|
121
|
+
ctx.send({ profile: null });
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
server.command("auth.register", async (data, ctx) => {
|
|
126
|
+
const [row] = await sql<[{ result: UserRow }]>`
|
|
127
|
+
SELECT cmd_auth_register(${sql.json(data)}) as result
|
|
128
|
+
`;
|
|
129
|
+
if (!row?.result) throw new Error("Registration failed");
|
|
130
|
+
|
|
131
|
+
const user = toUser(row.result);
|
|
132
|
+
const token = await signToken(user.id);
|
|
133
|
+
ctx.setUserId(user.id);
|
|
134
|
+
return { token, user } as AuthResult;
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
server.command("auth.login", async (data, ctx) => {
|
|
138
|
+
const [row] = await sql<[{ result: UserRow }]>`
|
|
139
|
+
SELECT cmd_auth_login(${sql.json(data)}) as result
|
|
140
|
+
`;
|
|
141
|
+
if (!row?.result) throw new Error("Invalid email or password");
|
|
142
|
+
|
|
143
|
+
const user = toUser(row.result);
|
|
144
|
+
const token = await signToken(user.id);
|
|
145
|
+
ctx.setUserId(user.id);
|
|
146
|
+
return { token, user } as AuthResult;
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
server.query("auth.profile", async function* (_params, ctx) {
|
|
150
|
+
if (!ctx.userId) throw new Error("Not authenticated");
|
|
151
|
+
const rows = await sql<{ query_auth_profile: UserRow }[]>`
|
|
152
|
+
SELECT query_auth_profile(${ctx.userId}, ${sql.json({})})
|
|
153
|
+
`;
|
|
154
|
+
for (const row of rows) {
|
|
155
|
+
yield toUser(row.query_auth_profile);
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export const channels: string[] = [];
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Command, Query } from "seiro";
|
|
2
|
+
|
|
3
|
+
export type User = {
|
|
4
|
+
id: number;
|
|
5
|
+
email: string;
|
|
6
|
+
createdAt: string;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export type AuthResult = {
|
|
10
|
+
token: string;
|
|
11
|
+
user: User;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type AuthCommands = {
|
|
15
|
+
"auth.register": Command<{ email: string; password: string }, AuthResult>;
|
|
16
|
+
"auth.login": Command<{ email: string; password: string }, AuthResult>;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type AuthQueries = {
|
|
20
|
+
"auth.profile": Query<void, User>;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type AuthEvents = Record<string, never>;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { signal, effect } from "seiro/client";
|
|
2
|
+
import type { Client } from "seiro";
|
|
3
|
+
import type { Commands, Queries, Events, User } from "../types";
|
|
4
|
+
|
|
5
|
+
type AppClient = Client<Commands, Queries, Events>;
|
|
6
|
+
|
|
7
|
+
// State
|
|
8
|
+
export const user = signal<User | null>(null);
|
|
9
|
+
const authError = signal<string | null>(null);
|
|
10
|
+
|
|
11
|
+
let client: AppClient;
|
|
12
|
+
|
|
13
|
+
export function initAuth(c: AppClient, profile: User | null) {
|
|
14
|
+
client = c;
|
|
15
|
+
user.value = profile;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function handleAuthSuccess(result: { token: string; user: User }) {
|
|
19
|
+
client.setToken(result.token);
|
|
20
|
+
user.value = result.user;
|
|
21
|
+
authError.value = null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function logout() {
|
|
25
|
+
client.logout();
|
|
26
|
+
user.value = null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
class AuthForm extends HTMLElement {
|
|
30
|
+
private userView!: HTMLElement;
|
|
31
|
+
private formsView!: HTMLElement;
|
|
32
|
+
private emailSpan!: HTMLElement;
|
|
33
|
+
private errorDiv!: HTMLElement;
|
|
34
|
+
|
|
35
|
+
connectedCallback() {
|
|
36
|
+
this.innerHTML = `
|
|
37
|
+
<div class="auth-user hidden items-center gap-4">
|
|
38
|
+
<span class="text-zinc-300">Logged in as <strong class="user-email text-white"></strong></span>
|
|
39
|
+
<button id="logout" class="bg-zinc-700 hover:bg-zinc-600 text-white px-3 py-1 rounded text-sm">Logout</button>
|
|
40
|
+
</div>
|
|
41
|
+
<div class="auth-forms space-y-4">
|
|
42
|
+
<form id="login-form" class="flex flex-wrap items-center gap-2">
|
|
43
|
+
<span class="text-zinc-400 w-20">Login</span>
|
|
44
|
+
<input name="email" type="email" placeholder="Email" value="test@example.com" required
|
|
45
|
+
class="bg-zinc-800 border border-zinc-700 rounded px-3 py-1.5 text-sm text-white placeholder-zinc-500 focus:outline-none focus:border-zinc-500" />
|
|
46
|
+
<input name="password" type="password" placeholder="Password" value="password123" required
|
|
47
|
+
class="bg-zinc-800 border border-zinc-700 rounded px-3 py-1.5 text-sm text-white placeholder-zinc-500 focus:outline-none focus:border-zinc-500" />
|
|
48
|
+
<button type="submit" class="bg-zinc-700 hover:bg-zinc-600 text-white px-4 py-1.5 rounded text-sm">Login</button>
|
|
49
|
+
</form>
|
|
50
|
+
<form id="register-form" class="flex flex-wrap items-center gap-2">
|
|
51
|
+
<span class="text-zinc-400 w-20">Register</span>
|
|
52
|
+
<input name="email" type="email" placeholder="Email" value="test@example.com" required
|
|
53
|
+
class="bg-zinc-800 border border-zinc-700 rounded px-3 py-1.5 text-sm text-white placeholder-zinc-500 focus:outline-none focus:border-zinc-500" />
|
|
54
|
+
<input name="password" type="password" placeholder="Password" value="password123" required
|
|
55
|
+
class="bg-zinc-800 border border-zinc-700 rounded px-3 py-1.5 text-sm text-white placeholder-zinc-500 focus:outline-none focus:border-zinc-500" />
|
|
56
|
+
<button type="submit" class="bg-zinc-700 hover:bg-zinc-600 text-white px-4 py-1.5 rounded text-sm">Register</button>
|
|
57
|
+
</form>
|
|
58
|
+
<div class="auth-error hidden text-red-400 text-sm"></div>
|
|
59
|
+
</div>
|
|
60
|
+
`;
|
|
61
|
+
|
|
62
|
+
this.userView = this.querySelector(".auth-user")!;
|
|
63
|
+
this.formsView = this.querySelector(".auth-forms")!;
|
|
64
|
+
this.emailSpan = this.querySelector(".user-email")!;
|
|
65
|
+
this.errorDiv = this.querySelector(".auth-error")!;
|
|
66
|
+
|
|
67
|
+
// Update visibility based on state
|
|
68
|
+
effect(() => {
|
|
69
|
+
if (user.value) {
|
|
70
|
+
this.userView.classList.remove("hidden");
|
|
71
|
+
this.userView.classList.add("flex");
|
|
72
|
+
this.formsView.classList.add("hidden");
|
|
73
|
+
this.emailSpan.textContent = user.value.email;
|
|
74
|
+
} else {
|
|
75
|
+
this.userView.classList.add("hidden");
|
|
76
|
+
this.userView.classList.remove("flex");
|
|
77
|
+
this.formsView.classList.remove("hidden");
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
effect(() => {
|
|
82
|
+
if (authError.value) {
|
|
83
|
+
this.errorDiv.classList.remove("hidden");
|
|
84
|
+
this.errorDiv.textContent = authError.value;
|
|
85
|
+
} else {
|
|
86
|
+
this.errorDiv.classList.add("hidden");
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
this.addEventListener("submit", (e) => {
|
|
91
|
+
e.preventDefault();
|
|
92
|
+
const form = e.target as HTMLFormElement;
|
|
93
|
+
const data = new FormData(form);
|
|
94
|
+
const email = data.get("email") as string;
|
|
95
|
+
const password = data.get("password") as string;
|
|
96
|
+
|
|
97
|
+
const callbacks = {
|
|
98
|
+
onSuccess: handleAuthSuccess,
|
|
99
|
+
onError: (err: string) => {
|
|
100
|
+
authError.value = err;
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
if (form.id === "login-form") {
|
|
105
|
+
client.cmd("auth.login", { email, password }, callbacks);
|
|
106
|
+
} else if (form.id === "register-form") {
|
|
107
|
+
client.cmd("auth.register", { email, password }, callbacks);
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
this.addEventListener("click", (e) => {
|
|
112
|
+
const target = e.target as HTMLElement;
|
|
113
|
+
if (target.id === "logout") {
|
|
114
|
+
logout();
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
customElements.define("auth-form", AuthForm);
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// Modal utilities - styled overlays, no browser dialogs
|
|
2
|
+
|
|
3
|
+
export function showToast(message: string, duration = 3000): void {
|
|
4
|
+
const toast = document.createElement("div");
|
|
5
|
+
toast.style.cssText =
|
|
6
|
+
"position:fixed;bottom:1rem;left:50%;transform:translateX(-50%);background:#27272a;border:1px solid #3f3f46;padding:0.5rem 1rem;border-radius:0.5rem;color:#f4f4f5;z-index:9999";
|
|
7
|
+
toast.textContent = message;
|
|
8
|
+
|
|
9
|
+
document.body.appendChild(toast);
|
|
10
|
+
setTimeout(() => toast.remove(), duration);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Simple modal for text input - replaces window.prompt()
|
|
14
|
+
|
|
15
|
+
export function showInputModal(
|
|
16
|
+
title: string,
|
|
17
|
+
initialValue = "",
|
|
18
|
+
): Promise<string | null> {
|
|
19
|
+
return new Promise((resolve) => {
|
|
20
|
+
const overlay = document.createElement("div");
|
|
21
|
+
overlay.style.cssText =
|
|
22
|
+
"position:fixed;inset:0;display:flex;align-items:center;justify-content:center;z-index:9999;background:rgba(0,0,0,0.5)";
|
|
23
|
+
overlay.innerHTML = `
|
|
24
|
+
<div style="background:#27272a;color:#f4f4f5;padding:1.5rem;border-radius:0.5rem;min-width:18rem">
|
|
25
|
+
<h2 style="font-size:1.125rem;font-weight:bold;margin-bottom:1rem">${title}</h2>
|
|
26
|
+
<input data-input style="width:100%;background:#3f3f46;border:1px solid #52525b;border-radius:0.375rem;padding:0.5rem 0.75rem;color:#f4f4f5;outline:none;margin-bottom:1rem" />
|
|
27
|
+
<div style="display:flex;gap:0.5rem">
|
|
28
|
+
<button data-cancel style="flex:1;background:#3f3f46;padding:0.5rem 1rem;border-radius:0.375rem;cursor:pointer;border:none;color:#f4f4f5">Cancel</button>
|
|
29
|
+
<button data-ok style="flex:1;background:#52525b;padding:0.5rem 1rem;border-radius:0.375rem;cursor:pointer;border:none;color:#f4f4f5">OK</button>
|
|
30
|
+
</div>
|
|
31
|
+
</div>
|
|
32
|
+
`;
|
|
33
|
+
|
|
34
|
+
const input = overlay.querySelector<HTMLInputElement>("[data-input]")!;
|
|
35
|
+
input.value = initialValue;
|
|
36
|
+
|
|
37
|
+
const cancelBtn =
|
|
38
|
+
overlay.querySelector<HTMLButtonElement>("[data-cancel]")!;
|
|
39
|
+
const okBtn = overlay.querySelector<HTMLButtonElement>("[data-ok]")!;
|
|
40
|
+
|
|
41
|
+
const close = (value: string | null) => {
|
|
42
|
+
overlay.remove();
|
|
43
|
+
resolve(value);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const submit = () => {
|
|
47
|
+
const value = input.value.trim();
|
|
48
|
+
close(value || null);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
okBtn.onclick = submit;
|
|
52
|
+
cancelBtn.onclick = () => close(null);
|
|
53
|
+
input.onkeydown = (e) => {
|
|
54
|
+
if (e.key === "Enter") submit();
|
|
55
|
+
if (e.key === "Escape") close(null);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// Close on backdrop click
|
|
59
|
+
overlay.onclick = (e) => {
|
|
60
|
+
if (e.target === overlay) close(null);
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
document.body.appendChild(overlay);
|
|
64
|
+
queueMicrotask(() => {
|
|
65
|
+
input.focus();
|
|
66
|
+
input.select();
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Form modal with multiple fields
|
|
72
|
+
|
|
73
|
+
export type FormField = {
|
|
74
|
+
name: string;
|
|
75
|
+
label: string;
|
|
76
|
+
placeholder?: string;
|
|
77
|
+
required?: boolean;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export function showFormModal(
|
|
81
|
+
title: string,
|
|
82
|
+
fields: FormField[],
|
|
83
|
+
): Promise<Record<string, string> | null> {
|
|
84
|
+
return new Promise((resolve) => {
|
|
85
|
+
const overlay = document.createElement("div");
|
|
86
|
+
overlay.style.cssText =
|
|
87
|
+
"position:fixed;inset:0;display:flex;align-items:center;justify-content:center;z-index:9999;background:rgba(0,0,0,0.5)";
|
|
88
|
+
|
|
89
|
+
const fieldsHtml = fields
|
|
90
|
+
.map(
|
|
91
|
+
(f) => `
|
|
92
|
+
<div style="display:flex;flex-direction:column;gap:0.25rem">
|
|
93
|
+
<label style="font-size:0.875rem;color:#a1a1aa">${f.label}${f.required ? " *" : ""}</label>
|
|
94
|
+
<input name="${f.name}" placeholder="${f.placeholder || ""}" ${f.required ? "required" : ""}
|
|
95
|
+
style="width:100%;background:#3f3f46;border:1px solid #52525b;border-radius:0.375rem;padding:0.5rem 0.75rem;color:#f4f4f5;outline:none" />
|
|
96
|
+
</div>
|
|
97
|
+
`,
|
|
98
|
+
)
|
|
99
|
+
.join("");
|
|
100
|
+
|
|
101
|
+
overlay.innerHTML = `
|
|
102
|
+
<form style="background:#27272a;color:#f4f4f5;padding:1.5rem;border-radius:0.5rem;min-width:20rem">
|
|
103
|
+
<h2 style="font-size:1.125rem;font-weight:bold;margin-bottom:1rem">${title}</h2>
|
|
104
|
+
<div style="display:flex;flex-direction:column;gap:0.75rem;margin-bottom:1rem">
|
|
105
|
+
${fieldsHtml}
|
|
106
|
+
</div>
|
|
107
|
+
<div style="display:flex;gap:0.5rem">
|
|
108
|
+
<button type="button" data-cancel style="flex:1;background:#3f3f46;padding:0.5rem 1rem;border-radius:0.375rem;cursor:pointer;border:none;color:#f4f4f5">Cancel</button>
|
|
109
|
+
<button type="submit" style="flex:1;background:#52525b;padding:0.5rem 1rem;border-radius:0.375rem;cursor:pointer;border:none;color:#f4f4f5">OK</button>
|
|
110
|
+
</div>
|
|
111
|
+
</form>
|
|
112
|
+
`;
|
|
113
|
+
|
|
114
|
+
const form = overlay.querySelector<HTMLFormElement>("form")!;
|
|
115
|
+
const cancelBtn =
|
|
116
|
+
overlay.querySelector<HTMLButtonElement>("[data-cancel]")!;
|
|
117
|
+
const firstInput = form.querySelector<HTMLInputElement>("input")!;
|
|
118
|
+
|
|
119
|
+
const close = (value: Record<string, string> | null) => {
|
|
120
|
+
overlay.remove();
|
|
121
|
+
resolve(value);
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
form.onsubmit = (e) => {
|
|
125
|
+
e.preventDefault();
|
|
126
|
+
const data = new FormData(form);
|
|
127
|
+
const result: Record<string, string> = {};
|
|
128
|
+
for (const field of fields) {
|
|
129
|
+
result[field.name] = (data.get(field.name) as string)?.trim() || "";
|
|
130
|
+
}
|
|
131
|
+
// Check required fields
|
|
132
|
+
for (const field of fields) {
|
|
133
|
+
if (field.required && !result[field.name]) return;
|
|
134
|
+
}
|
|
135
|
+
close(result);
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
cancelBtn.onclick = () => close(null);
|
|
139
|
+
|
|
140
|
+
overlay.onclick = (e) => {
|
|
141
|
+
if (e.target === overlay) close(null);
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
overlay.onkeydown = (e) => {
|
|
145
|
+
if (e.key === "Escape") close(null);
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
document.body.appendChild(overlay);
|
|
149
|
+
queueMicrotask(() => firstInput?.focus());
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Simple confirmation modal - replaces window.confirm()
|
|
154
|
+
|
|
155
|
+
export function showConfirmModal(
|
|
156
|
+
title: string,
|
|
157
|
+
message: string,
|
|
158
|
+
): Promise<boolean> {
|
|
159
|
+
return new Promise((resolve) => {
|
|
160
|
+
const overlay = document.createElement("div");
|
|
161
|
+
overlay.style.cssText =
|
|
162
|
+
"position:fixed;inset:0;display:flex;align-items:center;justify-content:center;z-index:9999;background:rgba(0,0,0,0.5)";
|
|
163
|
+
overlay.innerHTML = `
|
|
164
|
+
<div style="background:#27272a;color:#f4f4f5;padding:1.5rem;border-radius:0.5rem;min-width:18rem">
|
|
165
|
+
<h2 style="font-size:1.125rem;font-weight:bold;margin-bottom:0.75rem">${title}</h2>
|
|
166
|
+
<p style="color:#a1a1aa;margin-bottom:1rem">${message}</p>
|
|
167
|
+
<div style="display:flex;gap:0.5rem">
|
|
168
|
+
<button data-cancel style="flex:1;background:#3f3f46;padding:0.5rem 1rem;border-radius:0.375rem;cursor:pointer;border:none;color:#f4f4f5">Cancel</button>
|
|
169
|
+
<button data-ok style="flex:1;background:#b91c1c;padding:0.5rem 1rem;border-radius:0.375rem;cursor:pointer;border:none;color:#f4f4f5">Confirm</button>
|
|
170
|
+
</div>
|
|
171
|
+
</div>
|
|
172
|
+
`;
|
|
173
|
+
|
|
174
|
+
const cancelBtn =
|
|
175
|
+
overlay.querySelector<HTMLButtonElement>("[data-cancel]")!;
|
|
176
|
+
const okBtn = overlay.querySelector<HTMLButtonElement>("[data-ok]")!;
|
|
177
|
+
|
|
178
|
+
let closed = false;
|
|
179
|
+
const close = (value: boolean) => {
|
|
180
|
+
if (closed) return;
|
|
181
|
+
closed = true;
|
|
182
|
+
document.removeEventListener("keydown", handleKey);
|
|
183
|
+
overlay.remove();
|
|
184
|
+
resolve(value);
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
okBtn.onclick = () => close(true);
|
|
188
|
+
cancelBtn.onclick = () => close(false);
|
|
189
|
+
|
|
190
|
+
// Close on backdrop click
|
|
191
|
+
overlay.onclick = (e) => {
|
|
192
|
+
if (e.target === overlay) close(false);
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
// Handle keyboard
|
|
196
|
+
const handleKey = (e: KeyboardEvent) => {
|
|
197
|
+
if (e.key === "Escape") close(false);
|
|
198
|
+
if (e.key === "Enter") close(true);
|
|
199
|
+
};
|
|
200
|
+
document.addEventListener("keydown", handleKey);
|
|
201
|
+
|
|
202
|
+
document.body.appendChild(overlay);
|
|
203
|
+
queueMicrotask(() => okBtn.focus());
|
|
204
|
+
});
|
|
205
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { signal } from "seiro/client";
|
|
2
|
+
|
|
3
|
+
export const route = signal(window.location.hash || "#/");
|
|
4
|
+
|
|
5
|
+
window.addEventListener("hashchange", () => {
|
|
6
|
+
route.value = window.location.hash || "#/";
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
export function navigate(hash: string) {
|
|
10
|
+
window.location.hash = hash;
|
|
11
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
name: seiro-test
|
|
2
|
+
|
|
3
|
+
services:
|
|
4
|
+
postgres:
|
|
5
|
+
image: postgres:17-alpine
|
|
6
|
+
environment:
|
|
7
|
+
POSTGRES_USER: seiro
|
|
8
|
+
POSTGRES_PASSWORD: seiro
|
|
9
|
+
POSTGRES_DB: seiro_test
|
|
10
|
+
ports:
|
|
11
|
+
- "5433:5432"
|
|
12
|
+
volumes:
|
|
13
|
+
- ./init_db:/docker-entrypoint-initdb.d:ro
|
|
14
|
+
healthcheck:
|
|
15
|
+
test: ["CMD-SHELL", "pg_isready -U seiro"]
|
|
16
|
+
interval: 2s
|
|
17
|
+
timeout: 5s
|
|
18
|
+
retries: 10
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
name: seiro-dev
|
|
2
|
+
|
|
3
|
+
services:
|
|
4
|
+
postgres:
|
|
5
|
+
image: postgres:17-alpine
|
|
6
|
+
environment:
|
|
7
|
+
POSTGRES_USER: seiro
|
|
8
|
+
POSTGRES_PASSWORD: seiro
|
|
9
|
+
POSTGRES_DB: seiro
|
|
10
|
+
ports:
|
|
11
|
+
- "5432:5432"
|
|
12
|
+
volumes:
|
|
13
|
+
- ./init_db:/docker-entrypoint-initdb.d:ro
|
|
14
|
+
healthcheck:
|
|
15
|
+
test: ["CMD-SHELL", "pg_isready -U seiro"]
|
|
16
|
+
interval: 2s
|
|
17
|
+
timeout: 5s
|
|
18
|
+
retries: 10
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>Seiro App</title>
|
|
7
|
+
<script src="https://cdn.tailwindcss.com"></script>
|
|
8
|
+
</head>
|
|
9
|
+
<body class="bg-zinc-900 text-zinc-100 min-h-screen">
|
|
10
|
+
<header class="border-b border-zinc-800 p-4">
|
|
11
|
+
<div class="max-w-6xl mx-auto flex items-center justify-between">
|
|
12
|
+
<h1 class="text-xl font-bold">Seiro App</h1>
|
|
13
|
+
<auth-form></auth-form>
|
|
14
|
+
</div>
|
|
15
|
+
</header>
|
|
16
|
+
<main class="max-w-6xl mx-auto p-4">
|
|
17
|
+
<div class="text-center py-12">
|
|
18
|
+
<p class="text-zinc-400">Loading...</p>
|
|
19
|
+
</div>
|
|
20
|
+
</main>
|
|
21
|
+
<script type="module" src="/app.js"></script>
|
|
22
|
+
</body>
|
|
23
|
+
</html>
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
-- Auth functions
|
|
2
|
+
|
|
3
|
+
CREATE OR REPLACE FUNCTION cmd_auth_register(data JSONB)
|
|
4
|
+
RETURNS JSONB AS $$
|
|
5
|
+
DECLARE
|
|
6
|
+
v_user RECORD;
|
|
7
|
+
BEGIN
|
|
8
|
+
INSERT INTO users (email, password)
|
|
9
|
+
VALUES (
|
|
10
|
+
data->>'email',
|
|
11
|
+
crypt(data->>'password', gen_salt('bf'))
|
|
12
|
+
)
|
|
13
|
+
RETURNING id, email, created_at INTO v_user;
|
|
14
|
+
|
|
15
|
+
RETURN jsonb_build_object(
|
|
16
|
+
'id', v_user.id,
|
|
17
|
+
'email', v_user.email,
|
|
18
|
+
'created_at', v_user.created_at
|
|
19
|
+
);
|
|
20
|
+
EXCEPTION
|
|
21
|
+
WHEN unique_violation THEN
|
|
22
|
+
RAISE EXCEPTION 'Email already registered';
|
|
23
|
+
END;
|
|
24
|
+
$$ LANGUAGE plpgsql;
|
|
25
|
+
|
|
26
|
+
CREATE OR REPLACE FUNCTION cmd_auth_login(data JSONB)
|
|
27
|
+
RETURNS JSONB AS $$
|
|
28
|
+
DECLARE
|
|
29
|
+
v_user RECORD;
|
|
30
|
+
BEGIN
|
|
31
|
+
SELECT id, email, created_at INTO v_user
|
|
32
|
+
FROM users
|
|
33
|
+
WHERE email = data->>'email'
|
|
34
|
+
AND password = crypt(data->>'password', password);
|
|
35
|
+
|
|
36
|
+
IF v_user IS NULL THEN
|
|
37
|
+
RAISE EXCEPTION 'Invalid email or password';
|
|
38
|
+
END IF;
|
|
39
|
+
|
|
40
|
+
RETURN jsonb_build_object(
|
|
41
|
+
'id', v_user.id,
|
|
42
|
+
'email', v_user.email,
|
|
43
|
+
'created_at', v_user.created_at
|
|
44
|
+
);
|
|
45
|
+
END;
|
|
46
|
+
$$ LANGUAGE plpgsql;
|
|
47
|
+
|
|
48
|
+
CREATE OR REPLACE FUNCTION query_auth_profile(p_user_id INT, params JSONB DEFAULT '{}')
|
|
49
|
+
RETURNS SETOF JSONB AS $$
|
|
50
|
+
SELECT jsonb_build_object(
|
|
51
|
+
'id', id,
|
|
52
|
+
'email', email,
|
|
53
|
+
'created_at', created_at
|
|
54
|
+
) FROM users WHERE id = p_user_id;
|
|
55
|
+
$$ LANGUAGE sql;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "my-seiro-app",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"dev": "bun run --hot server.ts",
|
|
7
|
+
"check": "tsc --noEmit",
|
|
8
|
+
"db": "docker compose down -v && docker compose up -d",
|
|
9
|
+
"down": "docker compose down -v",
|
|
10
|
+
"test": "bun test server.test.ts"
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"seiro": "^0.1.0",
|
|
14
|
+
"@preact/signals-core": "^1.12.2",
|
|
15
|
+
"postgres": "^3.4.8"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"@types/bun": "latest",
|
|
19
|
+
"typescript": "^5.9.3"
|
|
20
|
+
}
|
|
21
|
+
}
|