zyket 1.2.18 → 1.2.19
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/SECURITY.md +40 -0
- package/bin/cli.js +282 -202
- package/index.js +4 -3
- package/package.json +5 -1
- package/src/extensions/bullboard/index.js +14 -3
- package/src/extensions/interactive-storage/index.js +25 -9
- package/src/extensions/interactive-storage/routes/delete-folder.js +13 -1
- package/src/extensions/interactive-storage/routes/delete.js +14 -3
- package/src/extensions/interactive-storage/routes/download.js +22 -3
- package/src/extensions/interactive-storage/routes/info.js +13 -0
- package/src/services/auth/index.js +46 -28
- package/src/services/express/Express.js +32 -15
- package/src/services/express/RequireAdminMiddleware.js +14 -0
- package/src/services/express/RequireAuthMiddleware.js +46 -0
- package/src/services/express/index.js +7 -5
- package/src/services/socketio/AuthGuard.js +33 -0
- package/src/services/socketio/SocketIO.js +3 -1
- package/src/services/socketio/index.js +2 -1
- package/src/services/template-manager/index.js +1 -0
- package/src/templates/api-rest/.env.example +24 -0
- package/src/templates/api-rest/README.md +50 -0
- package/src/templates/api-rest/index.js +18 -0
- package/src/templates/api-rest/src/models/Task.js +18 -0
- package/src/templates/api-rest/src/routes/tasks/[id].js +42 -0
- package/src/templates/api-rest/src/routes/tasks/index.js +26 -0
- package/src/templates/api-rest/src/services/auth/auth.js +9 -0
- package/src/templates/api-rest/src/services/auth/index.js +23 -0
- package/src/templates/realtime-chat/.env.example +26 -0
- package/src/templates/realtime-chat/README.md +38 -0
- package/src/templates/realtime-chat/frontend/.env.example +3 -0
- package/src/templates/realtime-chat/frontend/index.html +12 -0
- package/src/templates/realtime-chat/frontend/main.jsx +18 -0
- package/src/templates/realtime-chat/frontend/src/hooks/useAuth.jsx +27 -0
- package/src/templates/realtime-chat/frontend/src/hooks/useChatSocket.jsx +29 -0
- package/src/templates/realtime-chat/frontend/src/middlewares/LoggedMiddleware.jsx +12 -0
- package/src/templates/realtime-chat/frontend/src/middlewares/NotLoggedMiddleware.jsx +12 -0
- package/src/templates/realtime-chat/frontend/src/store/storeAuth.jsx +11 -0
- package/src/templates/realtime-chat/frontend/src/views/AuthView.jsx +70 -0
- package/src/templates/realtime-chat/frontend/src/views/ChatView.jsx +69 -0
- package/src/templates/realtime-chat/frontend/styles.css +1 -0
- package/src/templates/realtime-chat/frontend/vite.config.js +7 -0
- package/src/templates/realtime-chat/index.js +14 -0
- package/src/templates/realtime-chat/src/guards/auth.js +3 -0
- package/src/templates/realtime-chat/src/handlers/connection.js +23 -0
- package/src/templates/realtime-chat/src/handlers/message.js +29 -0
- package/src/templates/realtime-chat/src/services/auth/auth.js +8 -0
- package/src/templates/realtime-chat/src/services/auth/index.js +19 -0
- package/src/templates/saas-multitenant/.env.example +22 -0
- package/src/templates/saas-multitenant/README.md +71 -0
- package/src/templates/saas-multitenant/frontend/.env.example +3 -0
- package/src/templates/saas-multitenant/frontend/index.html +12 -0
- package/src/templates/saas-multitenant/frontend/main.jsx +18 -0
- package/src/templates/saas-multitenant/frontend/src/hooks/useAuth.jsx +27 -0
- package/src/templates/saas-multitenant/frontend/src/hooks/useProjects.jsx +41 -0
- package/src/templates/saas-multitenant/frontend/src/middlewares/LoggedMiddleware.jsx +12 -0
- package/src/templates/saas-multitenant/frontend/src/middlewares/NotLoggedMiddleware.jsx +12 -0
- package/src/templates/saas-multitenant/frontend/src/store/storeAuth.jsx +13 -0
- package/src/templates/saas-multitenant/frontend/src/views/AuthView.jsx +70 -0
- package/src/templates/saas-multitenant/frontend/src/views/DashboardView.jsx +131 -0
- package/src/templates/saas-multitenant/frontend/styles.css +1 -0
- package/src/templates/saas-multitenant/frontend/vite.config.js +7 -0
- package/src/templates/saas-multitenant/index.js +14 -0
- package/src/templates/saas-multitenant/src/middlewares/RequireOrganization.js +22 -0
- package/src/templates/saas-multitenant/src/models/Project.js +17 -0
- package/src/templates/saas-multitenant/src/routes/admin/stats.js +15 -0
- package/src/templates/saas-multitenant/src/routes/projects/index.js +34 -0
- package/src/templates/saas-multitenant/src/services/auth/auth.js +8 -0
- package/src/templates/saas-multitenant/src/services/auth/index.js +43 -0
- package/src/utils/EnvManager.js +23 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
module.exports = ({ sequelize, Sequelize }) => {
|
|
2
|
+
const Task = sequelize.define('Task', {
|
|
3
|
+
title: {
|
|
4
|
+
type: Sequelize.STRING,
|
|
5
|
+
allowNull: false,
|
|
6
|
+
},
|
|
7
|
+
description: {
|
|
8
|
+
type: Sequelize.TEXT,
|
|
9
|
+
allowNull: true,
|
|
10
|
+
},
|
|
11
|
+
completed: {
|
|
12
|
+
type: Sequelize.BOOLEAN,
|
|
13
|
+
defaultValue: false,
|
|
14
|
+
},
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
return Task;
|
|
18
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
const { Route, RequireAuthMiddleware } = require('zyket');
|
|
2
|
+
|
|
3
|
+
// Item endpoints: GET/PUT/DELETE /tasks/:id
|
|
4
|
+
module.exports = class TaskRoute extends Route {
|
|
5
|
+
middlewares = {
|
|
6
|
+
get: [new RequireAuthMiddleware()],
|
|
7
|
+
put: [new RequireAuthMiddleware()],
|
|
8
|
+
delete: [new RequireAuthMiddleware()],
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
async #find(container, id) {
|
|
12
|
+
const { Task } = container.get('database').models;
|
|
13
|
+
return Task.findByPk(id);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async get({ container, request }) {
|
|
17
|
+
const task = await this.#find(container, request.params.id);
|
|
18
|
+
if (!task) return { success: false, message: 'Task not found', status: 404 };
|
|
19
|
+
return { task };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async put({ container, request }) {
|
|
23
|
+
const task = await this.#find(container, request.params.id);
|
|
24
|
+
if (!task) return { success: false, message: 'Task not found', status: 404 };
|
|
25
|
+
|
|
26
|
+
const { title, completed, description } = request.body || {};
|
|
27
|
+
await task.update({
|
|
28
|
+
title: title ?? task.title,
|
|
29
|
+
description: description ?? task.description,
|
|
30
|
+
completed: completed ?? task.completed,
|
|
31
|
+
});
|
|
32
|
+
return { task };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async delete({ container, request }) {
|
|
36
|
+
const task = await this.#find(container, request.params.id);
|
|
37
|
+
if (!task) return { success: false, message: 'Task not found', status: 404 };
|
|
38
|
+
|
|
39
|
+
await task.destroy();
|
|
40
|
+
return { message: 'Task deleted' };
|
|
41
|
+
}
|
|
42
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
const { Route, RequireAuthMiddleware } = require('zyket');
|
|
2
|
+
|
|
3
|
+
// Collection endpoints: GET /tasks and POST /tasks
|
|
4
|
+
module.exports = class TasksRoute extends Route {
|
|
5
|
+
middlewares = {
|
|
6
|
+
get: [new RequireAuthMiddleware()],
|
|
7
|
+
post: [new RequireAuthMiddleware()],
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
async get({ container }) {
|
|
11
|
+
const { Task } = container.get('database').models;
|
|
12
|
+
const tasks = await Task.findAll({ order: [['createdAt', 'DESC']] });
|
|
13
|
+
return { tasks };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async post({ container, request }) {
|
|
17
|
+
const { title, description } = request.body || {};
|
|
18
|
+
if (!title) {
|
|
19
|
+
return { success: false, message: 'title is required', status: 400 };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const { Task } = container.get('database').models;
|
|
23
|
+
const task = await Task.create({ title, description });
|
|
24
|
+
return { task, status: 201 };
|
|
25
|
+
}
|
|
26
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
const { AuthService } = require('zyket');
|
|
2
|
+
|
|
3
|
+
module.exports = class CustomAuthService extends AuthService {
|
|
4
|
+
// This API does not use organizations/multi-tenancy.
|
|
5
|
+
get organizationEnabled() {
|
|
6
|
+
return false;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// Set to true once you wire a real email provider below.
|
|
10
|
+
get requireEmailVerification() {
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async sendResetPasswordEmail({ user, url }) {
|
|
15
|
+
// TODO: integrate your email provider. Logged for local development.
|
|
16
|
+
this.client && console.log(`[auth] Reset password for ${user.email}: ${url}`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async sendVerificationEmail({ user, url }) {
|
|
20
|
+
// TODO: integrate your email provider. Logged for local development.
|
|
21
|
+
console.log(`[auth] Verify email for ${user.email}: ${url}`);
|
|
22
|
+
}
|
|
23
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# --- realtime-chat template ---
|
|
2
|
+
DEBUG=true
|
|
3
|
+
PORT=3000
|
|
4
|
+
|
|
5
|
+
# HTTP (/api/auth), the socket server and the Vite frontend.
|
|
6
|
+
DISABLE_EXPRESS=false
|
|
7
|
+
DISABLE_SOCKET=false
|
|
8
|
+
DISABLE_VITE=false
|
|
9
|
+
VITE_ROOT=./frontend
|
|
10
|
+
VITE_PORT=5173
|
|
11
|
+
VITE_API_BASE=http://localhost:3000
|
|
12
|
+
|
|
13
|
+
# Set true only when frontend and backend live on different domains (needs HTTPS).
|
|
14
|
+
# AUTH_CROSS_DOMAIN=false
|
|
15
|
+
|
|
16
|
+
DATABASE_URL=./database.sqlite
|
|
17
|
+
DATABASE_DIALECT=sqlite
|
|
18
|
+
|
|
19
|
+
SOCKET_MAX_HTTP_BUFFER_SIZE=10485760
|
|
20
|
+
|
|
21
|
+
# Optional: scale Socket.IO across instances with a Redis adapter
|
|
22
|
+
# REDIS_URL=redis://localhost:6379
|
|
23
|
+
|
|
24
|
+
# AUTH_SECRET is generated automatically on first run.
|
|
25
|
+
# BETTER_AUTH_URL=http://localhost:3000
|
|
26
|
+
# TRUSTED_ORIGINS=http://localhost:3000
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# realtime-chat template
|
|
2
|
+
|
|
3
|
+
Authenticated real-time chat over Socket.IO. Sessions come from better-auth
|
|
4
|
+
(cookie sent during the websocket handshake), so only logged-in users can
|
|
5
|
+
connect or send messages.
|
|
6
|
+
|
|
7
|
+
## What's included
|
|
8
|
+
- `src/services/auth/` — email/password auth service.
|
|
9
|
+
- `src/guards/auth.js` — reuses the framework `AuthGuard` (session check per event).
|
|
10
|
+
- `src/handlers/connection.js` — enforces auth on connect, joins the `general` room.
|
|
11
|
+
- `src/handlers/message.js` — broadcasts `message` events to the room (guarded by `auth`).
|
|
12
|
+
- `frontend/` — React + Vite chat UI (login/register + live chat over Socket.IO).
|
|
13
|
+
|
|
14
|
+
## Setup
|
|
15
|
+
```bash
|
|
16
|
+
npm install
|
|
17
|
+
npx @better-auth/cli migrate
|
|
18
|
+
node index.js
|
|
19
|
+
```
|
|
20
|
+
This starts the API + websocket server on `:3000` and the Vite frontend on `:5173`.
|
|
21
|
+
Open http://localhost:5173, register a user, and start chatting (open two
|
|
22
|
+
browsers/users to see real-time delivery).
|
|
23
|
+
|
|
24
|
+
## How auth works here
|
|
25
|
+
1. The frontend signs in via better-auth → receives the session cookie.
|
|
26
|
+
2. The Socket.IO client connects with `withCredentials: true`; the cookie travels in the handshake.
|
|
27
|
+
3. `connection.js` validates the session and disconnects unauthenticated sockets.
|
|
28
|
+
|
|
29
|
+
## Cookies & local dev
|
|
30
|
+
Cookie security is now environment-aware (see the framework auth service):
|
|
31
|
+
- **Local dev** (default): `sameSite=lax`, not `secure` → works over `http://localhost`
|
|
32
|
+
because the Vite frontend (`:5173`) and API (`:3000`) are same-site.
|
|
33
|
+
- **Cross-domain** front/back in production: set `AUTH_CROSS_DOMAIN=true` (requires HTTPS)
|
|
34
|
+
to switch to `sameSite=none; secure`.
|
|
35
|
+
|
|
36
|
+
## Notes
|
|
37
|
+
- Set `REDIS_URL` to broadcast across multiple instances.
|
|
38
|
+
- `VITE_API_BASE` (frontend `.env`) points the UI at the backend; defaults to `http://localhost:3000`.
|
|
@@ -0,0 +1,12 @@
|
|
|
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>Zyket Chat</title>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<div id="root"></div>
|
|
10
|
+
<script type="module" src="./main.jsx"></script>
|
|
11
|
+
</body>
|
|
12
|
+
</html>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { createRoot } from 'react-dom/client'
|
|
2
|
+
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
|
3
|
+
import './styles.css'
|
|
4
|
+
import AuthView from './src/views/AuthView'
|
|
5
|
+
import ChatView from './src/views/ChatView'
|
|
6
|
+
import LoggedMiddleware from './src/middlewares/LoggedMiddleware'
|
|
7
|
+
import NotLoggedMiddleware from './src/middlewares/NotLoggedMiddleware'
|
|
8
|
+
|
|
9
|
+
createRoot(document.getElementById('root')).render(
|
|
10
|
+
<BrowserRouter>
|
|
11
|
+
<Routes>
|
|
12
|
+
<Route path="/" element={<Navigate to="/chat" replace />} />
|
|
13
|
+
<Route path="/auth" element={<NotLoggedMiddleware><AuthView /></NotLoggedMiddleware>} />
|
|
14
|
+
<Route path="/chat" element={<LoggedMiddleware><ChatView /></LoggedMiddleware>} />
|
|
15
|
+
<Route path="*" element={<Navigate to="/chat" replace />} />
|
|
16
|
+
</Routes>
|
|
17
|
+
</BrowserRouter>
|
|
18
|
+
)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { useStoreAuth } from "../store/storeAuth";
|
|
2
|
+
|
|
3
|
+
export default function useAuth() {
|
|
4
|
+
const context = useStoreAuth();
|
|
5
|
+
const sessionContext = context?.client?.useSession();
|
|
6
|
+
const { isPending, refetch } = sessionContext || {};
|
|
7
|
+
const { user, session } = sessionContext?.data || {};
|
|
8
|
+
|
|
9
|
+
const login = async (email, password) => {
|
|
10
|
+
const { data, error } = await context.client.signIn.email({ email, password });
|
|
11
|
+
if (error) throw error;
|
|
12
|
+
return data;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const register = async ({ email, password, name }) => {
|
|
16
|
+
const { data, error } = await context.client.signUp.email({ email, password, name });
|
|
17
|
+
if (error) throw error;
|
|
18
|
+
return data;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const logout = async () => {
|
|
22
|
+
await context.client.signOut();
|
|
23
|
+
window.location.href = "/auth";
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
return { ...context, login, register, logout, isPending, refetch, user, session };
|
|
27
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
import { io } from "socket.io-client";
|
|
3
|
+
import { useStoreAuth } from "../store/storeAuth";
|
|
4
|
+
|
|
5
|
+
// Connects to the Socket.IO server with the session cookie (withCredentials).
|
|
6
|
+
export default function useChatSocket() {
|
|
7
|
+
const { apiBase } = useStoreAuth();
|
|
8
|
+
const socketRef = useRef(null);
|
|
9
|
+
const [messages, setMessages] = useState([]);
|
|
10
|
+
const [connected, setConnected] = useState(false);
|
|
11
|
+
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
const socket = io(apiBase, { withCredentials: true });
|
|
14
|
+
socketRef.current = socket;
|
|
15
|
+
|
|
16
|
+
socket.on("connect", () => setConnected(true));
|
|
17
|
+
socket.on("disconnect", () => setConnected(false));
|
|
18
|
+
socket.on("message", (m) => setMessages((prev) => [...prev, m]));
|
|
19
|
+
|
|
20
|
+
return () => socket.disconnect();
|
|
21
|
+
}, [apiBase]);
|
|
22
|
+
|
|
23
|
+
const send = (text) =>
|
|
24
|
+
new Promise((resolve) => {
|
|
25
|
+
socketRef.current?.emit("message", { text }, resolve);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
return { messages, connected, send };
|
|
29
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import PropTypes from "prop-types";
|
|
2
|
+
import { Navigate } from "react-router-dom";
|
|
3
|
+
import useAuth from "../hooks/useAuth";
|
|
4
|
+
|
|
5
|
+
export default function LoggedMiddleware({ children }) {
|
|
6
|
+
const { user, isPending } = useAuth();
|
|
7
|
+
if (isPending) return null;
|
|
8
|
+
if (!user) return <Navigate to="/auth" replace />;
|
|
9
|
+
return children;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
LoggedMiddleware.propTypes = { children: PropTypes.node.isRequired };
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import PropTypes from "prop-types";
|
|
2
|
+
import { Navigate } from "react-router-dom";
|
|
3
|
+
import useAuth from "../hooks/useAuth";
|
|
4
|
+
|
|
5
|
+
export default function NotLoggedMiddleware({ children }) {
|
|
6
|
+
const { user, isPending } = useAuth();
|
|
7
|
+
if (isPending) return null;
|
|
8
|
+
if (user) return <Navigate to="/chat" replace />;
|
|
9
|
+
return children;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
NotLoggedMiddleware.propTypes = { children: PropTypes.node.isRequired };
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { create } from "zustand";
|
|
2
|
+
import { createAuthClient } from "better-auth/react";
|
|
3
|
+
|
|
4
|
+
const API_BASE = import.meta.env.VITE_API_BASE || "http://localhost:3000";
|
|
5
|
+
|
|
6
|
+
export const useStoreAuth = create(() => ({
|
|
7
|
+
apiBase: API_BASE,
|
|
8
|
+
client: createAuthClient({
|
|
9
|
+
baseURL: `${API_BASE}/api/auth/`,
|
|
10
|
+
}),
|
|
11
|
+
}));
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { useNavigate } from "react-router-dom";
|
|
3
|
+
import useAuth from "../hooks/useAuth";
|
|
4
|
+
|
|
5
|
+
export default function AuthView() {
|
|
6
|
+
const { login, register } = useAuth();
|
|
7
|
+
const navigate = useNavigate();
|
|
8
|
+
const [mode, setMode] = useState("login");
|
|
9
|
+
const [form, setForm] = useState({ name: "", email: "", password: "" });
|
|
10
|
+
const [error, setError] = useState(null);
|
|
11
|
+
const [loading, setLoading] = useState(false);
|
|
12
|
+
|
|
13
|
+
const submit = async (e) => {
|
|
14
|
+
e.preventDefault();
|
|
15
|
+
setError(null);
|
|
16
|
+
setLoading(true);
|
|
17
|
+
try {
|
|
18
|
+
if (mode === "login") await login(form.email, form.password);
|
|
19
|
+
else await register(form);
|
|
20
|
+
navigate("/chat", { replace: true });
|
|
21
|
+
} catch (err) {
|
|
22
|
+
setError(err?.message || "Authentication failed");
|
|
23
|
+
} finally {
|
|
24
|
+
setLoading(false);
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const field = (key, type = "text", placeholder = "") => (
|
|
29
|
+
<input
|
|
30
|
+
type={type}
|
|
31
|
+
placeholder={placeholder}
|
|
32
|
+
value={form[key]}
|
|
33
|
+
onChange={(e) => setForm({ ...form, [key]: e.target.value })}
|
|
34
|
+
className="w-full px-4 py-2 rounded-lg bg-zinc-800 border border-zinc-700 text-white outline-none focus:border-orange-500"
|
|
35
|
+
required
|
|
36
|
+
/>
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<div className="min-h-screen bg-zinc-950 text-white flex items-center justify-center p-4">
|
|
41
|
+
<form onSubmit={submit} className="w-full max-w-sm space-y-4 bg-zinc-900 p-8 rounded-2xl border border-zinc-800">
|
|
42
|
+
<h1 className="text-2xl font-bold text-center">
|
|
43
|
+
{mode === "login" ? "Sign in" : "Create account"}
|
|
44
|
+
</h1>
|
|
45
|
+
|
|
46
|
+
{mode === "register" && field("name", "text", "Name")}
|
|
47
|
+
{field("email", "email", "Email")}
|
|
48
|
+
{field("password", "password", "Password")}
|
|
49
|
+
|
|
50
|
+
{error && <p className="text-sm text-red-400">{error}</p>}
|
|
51
|
+
|
|
52
|
+
<button
|
|
53
|
+
type="submit"
|
|
54
|
+
disabled={loading}
|
|
55
|
+
className="w-full py-2 rounded-lg bg-orange-500 text-black font-semibold disabled:opacity-50"
|
|
56
|
+
>
|
|
57
|
+
{loading ? "..." : mode === "login" ? "Sign in" : "Sign up"}
|
|
58
|
+
</button>
|
|
59
|
+
|
|
60
|
+
<button
|
|
61
|
+
type="button"
|
|
62
|
+
onClick={() => setMode(mode === "login" ? "register" : "login")}
|
|
63
|
+
className="w-full text-sm text-zinc-400 hover:text-white"
|
|
64
|
+
>
|
|
65
|
+
{mode === "login" ? "Need an account? Sign up" : "Have an account? Sign in"}
|
|
66
|
+
</button>
|
|
67
|
+
</form>
|
|
68
|
+
</div>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { useState, useRef, useEffect } from "react";
|
|
2
|
+
import useAuth from "../hooks/useAuth";
|
|
3
|
+
import useChatSocket from "../hooks/useChatSocket";
|
|
4
|
+
|
|
5
|
+
export default function ChatView() {
|
|
6
|
+
const { user, logout } = useAuth();
|
|
7
|
+
const { messages, connected, send } = useChatSocket();
|
|
8
|
+
const [text, setText] = useState("");
|
|
9
|
+
const bottomRef = useRef(null);
|
|
10
|
+
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
|
13
|
+
}, [messages]);
|
|
14
|
+
|
|
15
|
+
const onSend = async (e) => {
|
|
16
|
+
e.preventDefault();
|
|
17
|
+
const value = text.trim();
|
|
18
|
+
if (!value) return;
|
|
19
|
+
setText("");
|
|
20
|
+
await send(value);
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<div className="min-h-screen bg-zinc-950 text-white flex flex-col">
|
|
25
|
+
<header className="flex items-center justify-between px-6 py-4 border-b border-zinc-800">
|
|
26
|
+
<div className="flex items-center gap-3">
|
|
27
|
+
<span className="font-bold">#general</span>
|
|
28
|
+
<span className={`text-xs ${connected ? "text-green-400" : "text-zinc-500"}`}>
|
|
29
|
+
{connected ? "● online" : "○ connecting…"}
|
|
30
|
+
</span>
|
|
31
|
+
</div>
|
|
32
|
+
<div className="flex items-center gap-3 text-sm">
|
|
33
|
+
<span className="text-zinc-400">{user?.name || user?.email}</span>
|
|
34
|
+
<button onClick={logout} className="text-zinc-400 hover:text-white">Logout</button>
|
|
35
|
+
</div>
|
|
36
|
+
</header>
|
|
37
|
+
|
|
38
|
+
<main className="flex-1 overflow-auto px-6 py-4 space-y-3">
|
|
39
|
+
{messages.length === 0 && (
|
|
40
|
+
<p className="text-zinc-600 text-sm">No messages yet. Say hi 👋</p>
|
|
41
|
+
)}
|
|
42
|
+
{messages.map((m) => {
|
|
43
|
+
const mine = m.user?.id === user?.id;
|
|
44
|
+
return (
|
|
45
|
+
<div key={m.id} className={`flex ${mine ? "justify-end" : "justify-start"}`}>
|
|
46
|
+
<div className={`max-w-md px-4 py-2 rounded-2xl ${mine ? "bg-orange-500 text-black" : "bg-zinc-800"}`}>
|
|
47
|
+
{!mine && <p className="text-xs text-zinc-400 mb-0.5">{m.user?.name}</p>}
|
|
48
|
+
<p className="text-sm break-words">{m.text}</p>
|
|
49
|
+
</div>
|
|
50
|
+
</div>
|
|
51
|
+
);
|
|
52
|
+
})}
|
|
53
|
+
<div ref={bottomRef} />
|
|
54
|
+
</main>
|
|
55
|
+
|
|
56
|
+
<form onSubmit={onSend} className="flex gap-2 px-6 py-4 border-t border-zinc-800">
|
|
57
|
+
<input
|
|
58
|
+
value={text}
|
|
59
|
+
onChange={(e) => setText(e.target.value)}
|
|
60
|
+
placeholder="Type a message…"
|
|
61
|
+
className="flex-1 px-4 py-2 rounded-lg bg-zinc-800 border border-zinc-700 outline-none focus:border-orange-500"
|
|
62
|
+
/>
|
|
63
|
+
<button type="submit" className="px-5 py-2 rounded-lg bg-orange-500 text-black font-semibold">
|
|
64
|
+
Send
|
|
65
|
+
</button>
|
|
66
|
+
</form>
|
|
67
|
+
</div>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
@import "tailwindcss";
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
const { Kernel } = require('zyket');
|
|
2
|
+
|
|
3
|
+
const kernel = new Kernel({
|
|
4
|
+
services: [
|
|
5
|
+
['auth', require('./src/services/auth'), ['@service_container']],
|
|
6
|
+
],
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
kernel.boot().then(async () => {
|
|
10
|
+
await kernel.container.get('database').sync();
|
|
11
|
+
kernel.container.get('logger').info('realtime-chat template ready (ws + http)');
|
|
12
|
+
}).catch((error) => {
|
|
13
|
+
console.error('Error booting kernel:', error);
|
|
14
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
const { Handler } = require('zyket');
|
|
2
|
+
|
|
3
|
+
// The connection handler enforces auth on connect: throwing here disconnects
|
|
4
|
+
// the socket (see the SocketIO service). Authenticated sockets join the room.
|
|
5
|
+
module.exports = class ConnectionHandler extends Handler {
|
|
6
|
+
async handle({ container, socket }) {
|
|
7
|
+
const session = await container.get('auth').getSession(socket.handshake.headers);
|
|
8
|
+
|
|
9
|
+
if (!session || !session.user) {
|
|
10
|
+
throw new Error('Unauthorized'); // disconnects the socket
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
socket.data.user = session.user;
|
|
14
|
+
socket.data.session = session.session;
|
|
15
|
+
socket.join('general');
|
|
16
|
+
|
|
17
|
+
container.get('logger').info(`Chat: ${session.user.email} connected (${socket.id})`);
|
|
18
|
+
socket.emit('welcome', {
|
|
19
|
+
room: 'general',
|
|
20
|
+
user: { id: session.user.id, name: session.user.name },
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
const { Handler } = require('zyket');
|
|
2
|
+
|
|
3
|
+
// Handles `socket.emit("message", { text })`. The `auth` guard re-validates the
|
|
4
|
+
// session before every message (defense in depth) and re-attaches the user.
|
|
5
|
+
module.exports = class MessageHandler extends Handler {
|
|
6
|
+
guards = ['auth'];
|
|
7
|
+
|
|
8
|
+
async handle({ container, socket, data, io }) {
|
|
9
|
+
const user = socket.data.user;
|
|
10
|
+
const text = String(data?.text || '').trim().slice(0, 2000);
|
|
11
|
+
|
|
12
|
+
if (!text) {
|
|
13
|
+
return { error: 'Empty message' };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const message = {
|
|
17
|
+
id: `${Date.now()}-${socket.id}`,
|
|
18
|
+
text,
|
|
19
|
+
user: { id: user.id, name: user.name },
|
|
20
|
+
at: new Date().toISOString(),
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
// Broadcast to everyone in the room (including sender).
|
|
24
|
+
io.to('general').emit('message', message);
|
|
25
|
+
|
|
26
|
+
container.get('logger').debug(`Chat message from ${user.email}`);
|
|
27
|
+
return { ok: true, message };
|
|
28
|
+
}
|
|
29
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
const { AuthService } = require('zyket');
|
|
2
|
+
|
|
3
|
+
module.exports = class CustomAuthService extends AuthService {
|
|
4
|
+
get organizationEnabled() {
|
|
5
|
+
return false;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
get requireEmailVerification() {
|
|
9
|
+
return false;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async sendResetPasswordEmail({ user, url }) {
|
|
13
|
+
console.log(`[auth] Reset password for ${user.email}: ${url}`);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async sendVerificationEmail({ user, url }) {
|
|
17
|
+
console.log(`[auth] Verify email for ${user.email}: ${url}`);
|
|
18
|
+
}
|
|
19
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# --- saas-multitenant template ---
|
|
2
|
+
DEBUG=true
|
|
3
|
+
PORT=3000
|
|
4
|
+
|
|
5
|
+
DISABLE_EXPRESS=false
|
|
6
|
+
DISABLE_SOCKET=true
|
|
7
|
+
DISABLE_VITE=false
|
|
8
|
+
VITE_ROOT=./frontend
|
|
9
|
+
VITE_PORT=5173
|
|
10
|
+
VITE_API_BASE=http://localhost:3000
|
|
11
|
+
|
|
12
|
+
DATABASE_URL=./database.sqlite
|
|
13
|
+
DATABASE_DIALECT=sqlite
|
|
14
|
+
|
|
15
|
+
HTTP_JSON_LIMIT=10mb
|
|
16
|
+
|
|
17
|
+
# Set true only when frontend and backend live on different domains (needs HTTPS).
|
|
18
|
+
# AUTH_CROSS_DOMAIN=false
|
|
19
|
+
|
|
20
|
+
# AUTH_SECRET is generated automatically on first run.
|
|
21
|
+
# BETTER_AUTH_URL=http://localhost:3000
|
|
22
|
+
# TRUSTED_ORIGINS=http://localhost:3000
|