zyket 1.2.17 → 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 +66 -42
- 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,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
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# saas-multitenant template
|
|
2
|
+
|
|
3
|
+
Multi-tenant SaaS starter built on better-auth's **organization** plugin
|
|
4
|
+
(organizations, members, roles, invitations) plus the **admin** plugin.
|
|
5
|
+
|
|
6
|
+
User/session/organization endpoints are all provided by better-auth — this
|
|
7
|
+
template focuses on the **config and the reusable gates** you need for your own
|
|
8
|
+
routes, not on re-implementing what better-auth already exposes.
|
|
9
|
+
|
|
10
|
+
## What's included
|
|
11
|
+
- `src/services/auth/` — auth service with organizations enabled, invitation email hook, and `allowUserToCreateOrganization`.
|
|
12
|
+
- `src/middlewares/RequireOrganization.js` — reusable gate that requires an active organization in the session.
|
|
13
|
+
- `src/models/Project.js` + `src/routes/projects` — a **tenant-scoped** resource: data isolated by organization (the core multi-tenant pattern).
|
|
14
|
+
- `src/routes/admin/stats` — minimal example of protecting a route with `RequireAdminMiddleware`.
|
|
15
|
+
- `frontend/` — React + Vite dashboard: sign in, create/switch organizations, and manage tenant-scoped projects.
|
|
16
|
+
|
|
17
|
+
## Org & auth endpoints (provided by better-auth, under `/api/auth`)
|
|
18
|
+
| Action | Endpoint |
|
|
19
|
+
|--------|----------|
|
|
20
|
+
| Sign up / in | `POST /api/auth/sign-up/email`, `POST /api/auth/sign-in/email` |
|
|
21
|
+
| Create organization | `POST /api/auth/organization/create` |
|
|
22
|
+
| Set active organization | `POST /api/auth/organization/set-active` |
|
|
23
|
+
| Invite member | `POST /api/auth/organization/invite-member` |
|
|
24
|
+
| Accept invitation | `POST /api/auth/organization/accept-invitation` |
|
|
25
|
+
|
|
26
|
+
## Setup
|
|
27
|
+
```bash
|
|
28
|
+
npm install
|
|
29
|
+
npx @better-auth/cli migrate # creates user/session/organization/member tables
|
|
30
|
+
node index.js
|
|
31
|
+
```
|
|
32
|
+
This starts the API on `:3000` and the Vite dashboard on `:5173`. Open
|
|
33
|
+
http://localhost:5173, sign up, create an organization, set it active, and add
|
|
34
|
+
projects (scoped to that organization).
|
|
35
|
+
|
|
36
|
+
### Cookies & local dev
|
|
37
|
+
Cookie security is environment-aware (framework auth service): local dev uses
|
|
38
|
+
`sameSite=lax` (works over `http://localhost`, frontend `:5173` + API `:3000` are
|
|
39
|
+
same-site). For cross-domain production set `AUTH_CROSS_DOMAIN=true` (needs HTTPS).
|
|
40
|
+
|
|
41
|
+
## Typical flow
|
|
42
|
+
1. `POST /api/auth/sign-up/email` → creates a user (session cookie returned).
|
|
43
|
+
2. `POST /api/auth/organization/create` `{ "name": "Acme", "slug": "acme" }`.
|
|
44
|
+
3. `POST /api/auth/organization/set-active` `{ "organizationId": "..." }`.
|
|
45
|
+
4. `GET /api/auth/get-session` → confirms the user and `activeOrganizationId`.
|
|
46
|
+
5. Invite teammates with `POST /api/auth/organization/invite-member` (the invite link is logged by `sendInvitationEmail`).
|
|
47
|
+
|
|
48
|
+
## Tenant-scoped routes (see `src/routes/projects`)
|
|
49
|
+
Compose the framework's `RequireAuthMiddleware` with this template's
|
|
50
|
+
`RequireOrganization` gate. After both run, `request.organizationId` is set and
|
|
51
|
+
every query is isolated to the active organization:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
# with an active organization set on the session
|
|
55
|
+
curl -X POST http://localhost:3000/projects -b cookies.txt \
|
|
56
|
+
-H "Content-Type: application/json" -d '{"name":"Website redesign"}'
|
|
57
|
+
|
|
58
|
+
curl http://localhost:3000/projects -b cookies.txt # only this org's projects
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
That isolation by `organizationId` — not the auth endpoints — is what better-auth
|
|
62
|
+
leaves to you; this route is the pattern to copy for your own resources.
|
|
63
|
+
|
|
64
|
+
## Roles
|
|
65
|
+
- Global roles (admin plugin): `user.role` — used by `RequireAdminMiddleware`. Promote with `POST /api/auth/admin/set-role`.
|
|
66
|
+
- Organization roles (owner/admin/member): stored per membership. Check them in your own middleware by querying the member record for `request.organizationId`.
|
|
67
|
+
|
|
68
|
+
## Notes
|
|
69
|
+
- Configure organization custom fields in `organizationAdditionalFields` (e.g. a `plan`).
|
|
70
|
+
- Wire a real email provider in `sendInvitationEmail` / `sendVerificationEmail`.
|
|
71
|
+
- See the framework `SECURITY.md` before going to production (CSRF/cookies/CORS).
|
|
@@ -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 SaaS</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 DashboardView from './src/views/DashboardView'
|
|
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="/dashboard" replace />} />
|
|
13
|
+
<Route path="/auth" element={<NotLoggedMiddleware><AuthView /></NotLoggedMiddleware>} />
|
|
14
|
+
<Route path="/dashboard" element={<LoggedMiddleware><DashboardView /></LoggedMiddleware>} />
|
|
15
|
+
<Route path="*" element={<Navigate to="/dashboard" 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,41 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState } from "react";
|
|
2
|
+
import { useStoreAuth } from "../store/storeAuth";
|
|
3
|
+
|
|
4
|
+
// Talks to the tenant-scoped /projects API (cookies sent with credentials).
|
|
5
|
+
// Only loads when there is an active organization.
|
|
6
|
+
export default function useProjects(activeOrganizationId) {
|
|
7
|
+
const { apiBase } = useStoreAuth();
|
|
8
|
+
const [projects, setProjects] = useState([]);
|
|
9
|
+
const [error, setError] = useState(null);
|
|
10
|
+
|
|
11
|
+
const load = useCallback(async () => {
|
|
12
|
+
setError(null);
|
|
13
|
+
const res = await fetch(`${apiBase}/projects`, { credentials: "include" });
|
|
14
|
+
const json = await res.json();
|
|
15
|
+
if (!res.ok) {
|
|
16
|
+
setError(json.message || "Failed to load projects");
|
|
17
|
+
setProjects([]);
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
setProjects(json.projects || []);
|
|
21
|
+
}, [apiBase]);
|
|
22
|
+
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
if (activeOrganizationId) load();
|
|
25
|
+
else setProjects([]);
|
|
26
|
+
}, [activeOrganizationId, load]);
|
|
27
|
+
|
|
28
|
+
const create = async (name) => {
|
|
29
|
+
const res = await fetch(`${apiBase}/projects`, {
|
|
30
|
+
method: "POST",
|
|
31
|
+
credentials: "include",
|
|
32
|
+
headers: { "Content-Type": "application/json" },
|
|
33
|
+
body: JSON.stringify({ name }),
|
|
34
|
+
});
|
|
35
|
+
const json = await res.json();
|
|
36
|
+
if (!res.ok) throw new Error(json.message || "Failed to create project");
|
|
37
|
+
await load();
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
return { projects, error, create, reload: load };
|
|
41
|
+
}
|
|
@@ -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="/dashboard" replace />;
|
|
9
|
+
return children;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
NotLoggedMiddleware.propTypes = { children: PropTypes.node.isRequired };
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { create } from "zustand";
|
|
2
|
+
import { createAuthClient } from "better-auth/react";
|
|
3
|
+
import { organizationClient, adminClient } from "better-auth/client/plugins";
|
|
4
|
+
|
|
5
|
+
const API_BASE = import.meta.env.VITE_API_BASE || "http://localhost:3000";
|
|
6
|
+
|
|
7
|
+
export const useStoreAuth = create(() => ({
|
|
8
|
+
apiBase: API_BASE,
|
|
9
|
+
client: createAuthClient({
|
|
10
|
+
baseURL: `${API_BASE}/api/auth/`,
|
|
11
|
+
plugins: [organizationClient(), adminClient()],
|
|
12
|
+
}),
|
|
13
|
+
}));
|
|
@@ -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("/dashboard", { 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-indigo-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-indigo-500 text-white 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,131 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import useAuth from "../hooks/useAuth";
|
|
3
|
+
import useProjects from "../hooks/useProjects";
|
|
4
|
+
|
|
5
|
+
export default function DashboardView() {
|
|
6
|
+
const { user, logout, client } = useAuth();
|
|
7
|
+
const { data: organizations } = client.useListOrganizations();
|
|
8
|
+
const { data: activeOrg } = client.useActiveOrganization();
|
|
9
|
+
|
|
10
|
+
const { projects, error: projectsError, create: createProject } = useProjects(activeOrg?.id);
|
|
11
|
+
|
|
12
|
+
const [orgName, setOrgName] = useState("");
|
|
13
|
+
const [projectName, setProjectName] = useState("");
|
|
14
|
+
const [busy, setBusy] = useState(false);
|
|
15
|
+
|
|
16
|
+
const createOrg = async (e) => {
|
|
17
|
+
e.preventDefault();
|
|
18
|
+
if (!orgName.trim()) return;
|
|
19
|
+
setBusy(true);
|
|
20
|
+
try {
|
|
21
|
+
const slug = orgName.trim().toLowerCase().replace(/\s+/g, "-");
|
|
22
|
+
await client.organization.create({ name: orgName.trim(), slug });
|
|
23
|
+
setOrgName("");
|
|
24
|
+
} finally {
|
|
25
|
+
setBusy(false);
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const setActive = async (organizationId) => {
|
|
30
|
+
await client.organization.setActive({ organizationId });
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const addProject = async (e) => {
|
|
34
|
+
e.preventDefault();
|
|
35
|
+
if (!projectName.trim()) return;
|
|
36
|
+
setBusy(true);
|
|
37
|
+
try {
|
|
38
|
+
await createProject(projectName.trim());
|
|
39
|
+
setProjectName("");
|
|
40
|
+
} finally {
|
|
41
|
+
setBusy(false);
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<div className="min-h-screen bg-zinc-950 text-white">
|
|
47
|
+
<header className="flex items-center justify-between px-6 py-4 border-b border-zinc-800">
|
|
48
|
+
<span className="font-bold">Zyket SaaS</span>
|
|
49
|
+
<div className="flex items-center gap-3 text-sm">
|
|
50
|
+
<span className="text-zinc-400">{user?.name || user?.email}</span>
|
|
51
|
+
<button onClick={logout} className="text-zinc-400 hover:text-white">Logout</button>
|
|
52
|
+
</div>
|
|
53
|
+
</header>
|
|
54
|
+
|
|
55
|
+
<main className="max-w-3xl mx-auto p-6 grid gap-6 md:grid-cols-2">
|
|
56
|
+
{/* Organizations */}
|
|
57
|
+
<section className="bg-zinc-900 border border-zinc-800 rounded-2xl p-5 space-y-4">
|
|
58
|
+
<h2 className="font-semibold">Organizations</h2>
|
|
59
|
+
|
|
60
|
+
<ul className="space-y-2">
|
|
61
|
+
{(organizations || []).map((org) => {
|
|
62
|
+
const isActive = org.id === activeOrg?.id;
|
|
63
|
+
return (
|
|
64
|
+
<li key={org.id} className="flex items-center justify-between">
|
|
65
|
+
<span className={isActive ? "text-indigo-400" : ""}>
|
|
66
|
+
{org.name} {isActive && "• active"}
|
|
67
|
+
</span>
|
|
68
|
+
{!isActive && (
|
|
69
|
+
<button
|
|
70
|
+
onClick={() => setActive(org.id)}
|
|
71
|
+
className="text-xs px-2 py-1 rounded bg-zinc-800 hover:bg-zinc-700"
|
|
72
|
+
>
|
|
73
|
+
Set active
|
|
74
|
+
</button>
|
|
75
|
+
)}
|
|
76
|
+
</li>
|
|
77
|
+
);
|
|
78
|
+
})}
|
|
79
|
+
{(!organizations || organizations.length === 0) && (
|
|
80
|
+
<li className="text-sm text-zinc-600">No organizations yet.</li>
|
|
81
|
+
)}
|
|
82
|
+
</ul>
|
|
83
|
+
|
|
84
|
+
<form onSubmit={createOrg} className="flex gap-2">
|
|
85
|
+
<input
|
|
86
|
+
value={orgName}
|
|
87
|
+
onChange={(e) => setOrgName(e.target.value)}
|
|
88
|
+
placeholder="New organization"
|
|
89
|
+
className="flex-1 px-3 py-2 rounded-lg bg-zinc-800 border border-zinc-700 outline-none focus:border-indigo-500 text-sm"
|
|
90
|
+
/>
|
|
91
|
+
<button disabled={busy} className="px-3 py-2 rounded-lg bg-indigo-500 text-white text-sm font-semibold disabled:opacity-50">
|
|
92
|
+
Create
|
|
93
|
+
</button>
|
|
94
|
+
</form>
|
|
95
|
+
</section>
|
|
96
|
+
|
|
97
|
+
{/* Projects (tenant-scoped) */}
|
|
98
|
+
<section className="bg-zinc-900 border border-zinc-800 rounded-2xl p-5 space-y-4">
|
|
99
|
+
<h2 className="font-semibold">Projects</h2>
|
|
100
|
+
|
|
101
|
+
{!activeOrg ? (
|
|
102
|
+
<p className="text-sm text-zinc-600">Set an active organization to manage projects.</p>
|
|
103
|
+
) : (
|
|
104
|
+
<>
|
|
105
|
+
<ul className="space-y-2">
|
|
106
|
+
{projects.map((p) => (
|
|
107
|
+
<li key={p.id} className="text-sm">• {p.name}</li>
|
|
108
|
+
))}
|
|
109
|
+
{projects.length === 0 && <li className="text-sm text-zinc-600">No projects yet.</li>}
|
|
110
|
+
</ul>
|
|
111
|
+
|
|
112
|
+
{projectsError && <p className="text-sm text-red-400">{projectsError}</p>}
|
|
113
|
+
|
|
114
|
+
<form onSubmit={addProject} className="flex gap-2">
|
|
115
|
+
<input
|
|
116
|
+
value={projectName}
|
|
117
|
+
onChange={(e) => setProjectName(e.target.value)}
|
|
118
|
+
placeholder="New project"
|
|
119
|
+
className="flex-1 px-3 py-2 rounded-lg bg-zinc-800 border border-zinc-700 outline-none focus:border-indigo-500 text-sm"
|
|
120
|
+
/>
|
|
121
|
+
<button disabled={busy} className="px-3 py-2 rounded-lg bg-indigo-500 text-white text-sm font-semibold disabled:opacity-50">
|
|
122
|
+
Add
|
|
123
|
+
</button>
|
|
124
|
+
</form>
|
|
125
|
+
</>
|
|
126
|
+
)}
|
|
127
|
+
</section>
|
|
128
|
+
</main>
|
|
129
|
+
</div>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
@@ -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('saas-multitenant template ready');
|
|
12
|
+
}).catch((error) => {
|
|
13
|
+
console.error('Error booting kernel:', error);
|
|
14
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
const { Middleware } = require('zyket');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Requires an authenticated user that has an ACTIVE organization in the
|
|
5
|
+
* current session. Run it AFTER RequireAuthMiddleware (which attaches
|
|
6
|
+
* `request.session`). Attaches `request.organizationId`.
|
|
7
|
+
*/
|
|
8
|
+
module.exports = class RequireOrganization extends Middleware {
|
|
9
|
+
async handle({ request, response, next }) {
|
|
10
|
+
const activeOrganizationId = request.session?.activeOrganizationId;
|
|
11
|
+
|
|
12
|
+
if (!activeOrganizationId) {
|
|
13
|
+
return response.status(403).json({
|
|
14
|
+
success: false,
|
|
15
|
+
message: 'No active organization. Set one via POST /api/auth/organization/set-active',
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
request.organizationId = activeOrganizationId;
|
|
20
|
+
next();
|
|
21
|
+
}
|
|
22
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
module.exports = ({ sequelize, Sequelize }) => {
|
|
2
|
+
// A tenant-scoped resource: every project belongs to one organization.
|
|
3
|
+
const Project = sequelize.define('Project', {
|
|
4
|
+
name: {
|
|
5
|
+
type: Sequelize.STRING,
|
|
6
|
+
allowNull: false,
|
|
7
|
+
},
|
|
8
|
+
organizationId: {
|
|
9
|
+
type: Sequelize.STRING,
|
|
10
|
+
allowNull: false,
|
|
11
|
+
},
|
|
12
|
+
}, {
|
|
13
|
+
indexes: [{ fields: ['organizationId'] }],
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
return Project;
|
|
17
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
const { Route, RequireAdminMiddleware } = require('zyket');
|
|
2
|
+
|
|
3
|
+
// Global admin-only endpoint (better-auth admin plugin role === 'admin').
|
|
4
|
+
module.exports = class AdminStatsRoute extends Route {
|
|
5
|
+
middlewares = {
|
|
6
|
+
get: [new RequireAdminMiddleware()],
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
async get() {
|
|
10
|
+
return {
|
|
11
|
+
message: 'Admin-only data',
|
|
12
|
+
// ...gather real stats here
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
const { Route, RequireAuthMiddleware } = require('zyket');
|
|
2
|
+
const RequireOrganization = require('../../middlewares/RequireOrganization');
|
|
3
|
+
|
|
4
|
+
// Tenant-scoped CRUD. RequireOrganization guarantees an active org and sets
|
|
5
|
+
// `request.organizationId`; every query is isolated to that organization.
|
|
6
|
+
module.exports = class ProjectsRoute extends Route {
|
|
7
|
+
middlewares = {
|
|
8
|
+
get: [new RequireAuthMiddleware(), new RequireOrganization()],
|
|
9
|
+
post: [new RequireAuthMiddleware(), new RequireOrganization()],
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
async get({ container, request }) {
|
|
13
|
+
const { Project } = container.get('database').models;
|
|
14
|
+
const projects = await Project.findAll({
|
|
15
|
+
where: { organizationId: request.organizationId },
|
|
16
|
+
order: [['createdAt', 'DESC']],
|
|
17
|
+
});
|
|
18
|
+
return { projects };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async post({ container, request }) {
|
|
22
|
+
const { name } = request.body || {};
|
|
23
|
+
if (!name) {
|
|
24
|
+
return { success: false, message: 'name is required', status: 400 };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const { Project } = container.get('database').models;
|
|
28
|
+
const project = await Project.create({
|
|
29
|
+
name,
|
|
30
|
+
organizationId: request.organizationId,
|
|
31
|
+
});
|
|
32
|
+
return { project, status: 201 };
|
|
33
|
+
}
|
|
34
|
+
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
const { AuthService } = require('zyket');
|
|
2
|
+
|
|
3
|
+
module.exports = class CustomAuthService extends AuthService {
|
|
4
|
+
// Multi-tenancy is the whole point of this template.
|
|
5
|
+
get organizationEnabled() {
|
|
6
|
+
return true;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
get requireEmailVerification() {
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
get organizationAdditionalFields() {
|
|
14
|
+
return {
|
|
15
|
+
// Example custom field on the organization:
|
|
16
|
+
// plan: { type: 'string', required: false, defaultValue: 'free' },
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
get memberAdditionalFields() {
|
|
21
|
+
return {};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Decide who is allowed to create new organizations.
|
|
25
|
+
async allowUserToCreateOrganization(/* user */) {
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async sendInvitationEmail(data) {
|
|
30
|
+
// TODO: send a real email with the invite link.
|
|
31
|
+
// `data` contains { id, email, organization, inviter, role, ... }
|
|
32
|
+
const acceptUrl = `${process.env.BETTER_AUTH_URL || 'http://localhost:3000'}/accept-invitation/${data.id}`;
|
|
33
|
+
console.log(`[auth] Invite ${data.email} to "${data.organization?.name}" -> ${acceptUrl}`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async sendResetPasswordEmail({ user, url }) {
|
|
37
|
+
console.log(`[auth] Reset password for ${user.email}: ${url}`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async sendVerificationEmail({ user, url }) {
|
|
41
|
+
console.log(`[auth] Verify email for ${user.email}: ${url}`);
|
|
42
|
+
}
|
|
43
|
+
};
|
package/src/utils/EnvManager.js
CHANGED
|
@@ -18,8 +18,12 @@ module.exports = class EnvManager {
|
|
|
18
18
|
const envsToCreate = {
|
|
19
19
|
DEBUG: true,
|
|
20
20
|
PORT: 3000,
|
|
21
|
+
HTTP_JSON_LIMIT: '10mb',
|
|
22
|
+
SOCKET_MAX_HTTP_BUFFER_SIZE: 10 * 1024 * 1024,
|
|
21
23
|
DISABLE_SOCKET: true,
|
|
22
24
|
DISABLE_EXPRESS: false,
|
|
25
|
+
DISABLE_SWAGGER: false,
|
|
26
|
+
SWAGGER_PASSWORD: '',
|
|
23
27
|
DISABLE_EVENTS: true,
|
|
24
28
|
DISABLE_BULLMQ: true,
|
|
25
29
|
DISABLE_SCHEDULER: true,
|
|
@@ -66,4 +70,23 @@ module.exports = class EnvManager {
|
|
|
66
70
|
fs.writeFileSync(secretsPath, envContent);
|
|
67
71
|
return true; // Key added successfully
|
|
68
72
|
}
|
|
73
|
+
|
|
74
|
+
// Upsert: replace the value if the key exists, otherwise append it.
|
|
75
|
+
static setEnvVariable(secretsPath, key, value) {
|
|
76
|
+
if (!fs.existsSync(secretsPath)) {
|
|
77
|
+
this.createEnvFile(secretsPath);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
let envContent = fs.readFileSync(secretsPath, 'utf-8');
|
|
81
|
+
const keyRegex = new RegExp(`^${key}=.*$`, 'm');
|
|
82
|
+
|
|
83
|
+
if (keyRegex.test(envContent)) {
|
|
84
|
+
envContent = envContent.replace(keyRegex, `${key}=${value}`);
|
|
85
|
+
} else {
|
|
86
|
+
if (envContent.length && !envContent.endsWith('\n')) envContent += '\n';
|
|
87
|
+
envContent += `${key}=${value}\n`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
fs.writeFileSync(secretsPath, envContent);
|
|
91
|
+
}
|
|
69
92
|
}
|