create-windy 0.2.4 → 0.2.6
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 +8 -0
- package/dist/cli.js +171 -107
- package/package.json +1 -1
- package/template/.dockerignore +1 -0
- package/template/.windy-template.json +2 -2
- package/template/AGENTS.md +1 -0
- package/template/README.md +15 -7
- package/template/apps/server/src/persistence.ts +2 -0
- package/template/apps/web/src/composables/usePlatformSettings.ts +3 -0
- package/template/apps/web/src/pages/ServiceUnavailablePage.vue +38 -0
- package/template/apps/web/src/pages/system/resource-config.ts +2 -0
- package/template/apps/web/src/router/auth-guard.webtest.ts +146 -0
- package/template/apps/web/src/router/auth-navigation.webtest.ts +14 -0
- package/template/apps/web/src/router/index.ts +25 -11
- package/template/apps/web/src/router/index.webtest.ts +26 -209
- package/template/apps/web/src/router/manifest-routes.webtest.ts +49 -0
- package/template/apps/web/src/router/router-test-fixtures.ts +75 -0
- package/template/package.json +1 -1
- package/template/packages/database/src/schema/governance.ts +27 -22
- package/template/packages/database/src/schema/platform-settings.ts +31 -17
- package/template/packages/modules/src/system-admin-routes.ts +2 -0
package/package.json
CHANGED
package/template/.dockerignore
CHANGED
package/template/AGENTS.md
CHANGED
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
|
|
34
34
|
## 常用入口
|
|
35
35
|
|
|
36
|
+
- `bun run dev`:通过 Docker Compose 启动 PostgreSQL、自动 migration、Server 与 Web。
|
|
36
37
|
- `bun run dev:web`:启动 Web 开发服务。
|
|
37
38
|
- `bun run dev:server`:启动 Server 开发服务。
|
|
38
39
|
- `bun run lint`、`bun run typecheck`、`bun run test`、`bun run build`:执行基础验证;未选择 Oxc 时没有 lint 脚本。
|
package/template/README.md
CHANGED
|
@@ -19,29 +19,36 @@ windy-starter 是由 create-windy 生成的单组织、单租户企业应用项
|
|
|
19
19
|
不要使用 npm、Yarn 或 pnpm 安装本项目依赖,也不要提交它们产生的 lockfile;唯一的
|
|
20
20
|
依赖锁文件是 `bun.lock`。
|
|
21
21
|
|
|
22
|
-
##
|
|
22
|
+
## 第一次运行:完整开发环境
|
|
23
23
|
|
|
24
24
|
```bash
|
|
25
25
|
bun install
|
|
26
|
-
|
|
27
|
-
# 为 POSTGRES_PASSWORD 和 WINDY_BOOTSTRAP_ADMIN_PASSWORD 设置本地值
|
|
28
|
-
docker compose up --build
|
|
26
|
+
bun run dev
|
|
29
27
|
```
|
|
30
28
|
|
|
29
|
+
create-windy 已创建仅供本机使用且被 Git 忽略的 `.env`,其中两个开发密码均为随机值,
|
|
30
|
+
文件权限为 `0600`。`bun run dev` 会通过 Docker Compose 等待 PostgreSQL、执行
|
|
31
|
+
Drizzle migration,再启动 Server 与 Web;不要跳过 migration 后直接把 Server 连接到
|
|
32
|
+
空数据库。首次启动需要本机已经安装并启动 Docker 与 Docker Compose,并可能需要下载
|
|
33
|
+
基础镜像。
|
|
34
|
+
|
|
31
35
|
| 服务 | 地址 |
|
|
32
36
|
| ---- | ---- |
|
|
33
37
|
| Web | http://localhost:9746 |
|
|
34
38
|
| Server 健康检查 | http://localhost:3000/api/health |
|
|
35
39
|
| PostgreSQL | localhost:5432 |
|
|
36
40
|
|
|
37
|
-
Docker Web 固定使用 `9746
|
|
41
|
+
Docker Web 固定使用 `9746`。只启动单个应用时使用:
|
|
38
42
|
|
|
39
43
|
```bash
|
|
40
44
|
bun run dev:web
|
|
45
|
+
bun run dev:server
|
|
41
46
|
```
|
|
42
47
|
|
|
43
|
-
|
|
44
|
-
|
|
48
|
+
`dev:web` 不会隐式启动后端;本地 Web 从 `8746` 开始探测。同一项目已经运行时会
|
|
49
|
+
直接输出已有地址,其它服务占用时会自动使用后续端口,最多检查 100 个。实际地址始终
|
|
50
|
+
以终端输出为准。`dev:server` 不会自动启动 PostgreSQL 或执行 migration;未配置
|
|
51
|
+
`DATABASE_URL` 时使用内存模式。
|
|
45
52
|
|
|
46
53
|
## 页面与登录
|
|
47
54
|
|
|
@@ -96,6 +103,7 @@ bun run dev:web
|
|
|
96
103
|
## 常用命令
|
|
97
104
|
|
|
98
105
|
```bash
|
|
106
|
+
bun run dev
|
|
99
107
|
bun run dev:web
|
|
100
108
|
bun run dev:server
|
|
101
109
|
bun run typecheck
|
|
@@ -102,6 +102,7 @@ export function createServerPersistence(
|
|
|
102
102
|
};
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
+
// @windy-module system.audit begin
|
|
105
106
|
function readPositiveInteger(
|
|
106
107
|
value: string | undefined,
|
|
107
108
|
fallback: number,
|
|
@@ -116,3 +117,4 @@ function readSensitiveFields(value: string | undefined): string[] | undefined {
|
|
|
116
117
|
.map((field) => field.trim())
|
|
117
118
|
.filter(Boolean);
|
|
118
119
|
}
|
|
120
|
+
// @windy-module system.audit end
|
|
@@ -25,6 +25,7 @@ async function load(force = false): Promise<PlatformBrandingSettings> {
|
|
|
25
25
|
if (!force && loaded) return branding.value;
|
|
26
26
|
loading.value = true;
|
|
27
27
|
error.value = "";
|
|
28
|
+
// oxlint-disable-next-line prefer-const -- 精简项目会裁剪下面的远端加载分支
|
|
28
29
|
let operation = Promise.resolve(applyBranding(branding.value));
|
|
29
30
|
// @windy-module system.settings begin
|
|
30
31
|
operation = loadPublicBranding()
|
|
@@ -70,9 +71,11 @@ function applyBranding(next: PlatformBrandingSettings) {
|
|
|
70
71
|
return branding.value;
|
|
71
72
|
}
|
|
72
73
|
|
|
74
|
+
// @windy-module system.settings begin
|
|
73
75
|
function message(error: unknown): string {
|
|
74
76
|
return error instanceof Error ? error.message : String(error);
|
|
75
77
|
}
|
|
78
|
+
// @windy-module system.settings end
|
|
76
79
|
|
|
77
80
|
export function usePlatformSettings() {
|
|
78
81
|
return {
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from "vue";
|
|
3
|
+
import { Button } from "@/components/ui/button";
|
|
4
|
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
5
|
+
|
|
6
|
+
const props = defineProps<{
|
|
7
|
+
reason: string;
|
|
8
|
+
from: string;
|
|
9
|
+
}>();
|
|
10
|
+
|
|
11
|
+
const message = computed(() =>
|
|
12
|
+
props.reason === "session-unavailable"
|
|
13
|
+
? "暂时无法连接后端服务,无法确认当前登录状态。请启动或恢复后端服务后重试。"
|
|
14
|
+
: "暂时无法获取服务端数据。请确认后端服务正常运行后重试。",
|
|
15
|
+
);
|
|
16
|
+
</script>
|
|
17
|
+
|
|
18
|
+
<template>
|
|
19
|
+
<div class="mx-auto flex min-h-96 max-w-xl items-center">
|
|
20
|
+
<Card class="w-full">
|
|
21
|
+
<CardHeader>
|
|
22
|
+
<CardTitle>后端服务暂不可用</CardTitle>
|
|
23
|
+
</CardHeader>
|
|
24
|
+
<CardContent class="space-y-4">
|
|
25
|
+
<p class="text-sm text-muted-foreground">{{ message }}</p>
|
|
26
|
+
<p class="text-xs text-muted-foreground">原请求地址:{{ from }}</p>
|
|
27
|
+
<div class="flex flex-wrap gap-3">
|
|
28
|
+
<Button as-child>
|
|
29
|
+
<RouterLink :to="from">重新连接</RouterLink>
|
|
30
|
+
</Button>
|
|
31
|
+
<Button as-child variant="outline">
|
|
32
|
+
<RouterLink to="/login">进入登录页</RouterLink>
|
|
33
|
+
</Button>
|
|
34
|
+
</div>
|
|
35
|
+
</CardContent>
|
|
36
|
+
</Card>
|
|
37
|
+
</div>
|
|
38
|
+
</template>
|
|
@@ -50,7 +50,9 @@ export interface SystemResourceConfig {
|
|
|
50
50
|
createDefaults: () => Record<string, unknown>;
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
// @windy-module system.audit begin
|
|
53
54
|
const emptyDefaults = () => ({});
|
|
55
|
+
// @windy-module system.audit end
|
|
54
56
|
|
|
55
57
|
export const systemResourceConfigs: Record<
|
|
56
58
|
SystemResourceKey,
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { createMemoryHistory } from "vue-router";
|
|
3
|
+
import { ApiError } from "@/services/http";
|
|
4
|
+
import { createAppRouter } from "./index";
|
|
5
|
+
import {
|
|
6
|
+
accessSnapshot,
|
|
7
|
+
authProvider,
|
|
8
|
+
// @windy-module system.license begin
|
|
9
|
+
licenseProvider,
|
|
10
|
+
// @windy-module system.license end
|
|
11
|
+
provider,
|
|
12
|
+
} from "./router-test-fixtures";
|
|
13
|
+
|
|
14
|
+
describe("Web 认证与服务异常导航", () => {
|
|
15
|
+
test("未认证直访受保护页面会保存安全 returnTo 并进入登录页", async () => {
|
|
16
|
+
const router = createAppRouter(
|
|
17
|
+
createMemoryHistory(),
|
|
18
|
+
provider(accessSnapshot()),
|
|
19
|
+
// @windy-module system.license begin
|
|
20
|
+
licenseProvider(),
|
|
21
|
+
// @windy-module system.license end
|
|
22
|
+
authProvider("anonymous"),
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
await router.push("/admin/system/users?page=2");
|
|
26
|
+
|
|
27
|
+
expect(router.currentRoute.value.name).toBe("login");
|
|
28
|
+
expect(router.currentRoute.value.query.returnTo).toBe(
|
|
29
|
+
"/admin/system/users?page=2",
|
|
30
|
+
);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("已认证用户访问登录页只接受站内绝对 returnTo", async () => {
|
|
34
|
+
const router = createAppRouter(
|
|
35
|
+
createMemoryHistory(),
|
|
36
|
+
provider(accessSnapshot()),
|
|
37
|
+
// @windy-module system.license begin
|
|
38
|
+
licenseProvider(),
|
|
39
|
+
// @windy-module system.license end
|
|
40
|
+
authProvider(),
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
await router.push({
|
|
44
|
+
name: "login",
|
|
45
|
+
query: { returnTo: "https://evil.test" },
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
expect(router.currentRoute.value.path).toBe("/");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("管理员重置后的账号只能进入强制改密页且不会重定向循环", async () => {
|
|
52
|
+
const session = authProvider();
|
|
53
|
+
session.actor.credentialState = "must-change-password";
|
|
54
|
+
const router = createAppRouter(
|
|
55
|
+
createMemoryHistory(),
|
|
56
|
+
provider(accessSnapshot()),
|
|
57
|
+
// @windy-module system.license begin
|
|
58
|
+
licenseProvider(),
|
|
59
|
+
// @windy-module system.license end
|
|
60
|
+
session,
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
await router.push("/admin/system/users");
|
|
64
|
+
expect(router.currentRoute.value.name).toBe("change-password");
|
|
65
|
+
|
|
66
|
+
await router.push("/change-password");
|
|
67
|
+
expect(router.currentRoute.value.name).toBe("change-password");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("后端未启动时登录页仍是可恢复的公开入口", async () => {
|
|
71
|
+
const router = createAppRouter(
|
|
72
|
+
createMemoryHistory(),
|
|
73
|
+
provider(accessSnapshot()),
|
|
74
|
+
// @windy-module system.license begin
|
|
75
|
+
licenseProvider(),
|
|
76
|
+
// @windy-module system.license end
|
|
77
|
+
offlineAuthProvider(new ApiError("Bad Gateway", 502)),
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
await router.push("/login");
|
|
81
|
+
|
|
82
|
+
expect(router.currentRoute.value.name).toBe("login");
|
|
83
|
+
expect(router.currentRoute.value.query).toEqual({});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("后端未启动时首页进入服务恢复页而非无权限页", async () => {
|
|
87
|
+
const router = createAppRouter(
|
|
88
|
+
createMemoryHistory(),
|
|
89
|
+
provider(accessSnapshot()),
|
|
90
|
+
// @windy-module system.license begin
|
|
91
|
+
licenseProvider(),
|
|
92
|
+
// @windy-module system.license end
|
|
93
|
+
offlineAuthProvider(new TypeError("Failed to fetch")),
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
await router.push("/");
|
|
97
|
+
|
|
98
|
+
expect(router.currentRoute.value.name).toBe("service-unavailable");
|
|
99
|
+
expect(router.currentRoute.value.query.reason).toBe("session-unavailable");
|
|
100
|
+
expect(router.currentRoute.value.query.from).toBe("/");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("权限快照网络异常与 403 权限拒绝使用不同页面", async () => {
|
|
104
|
+
const router = createAppRouter(
|
|
105
|
+
createMemoryHistory(),
|
|
106
|
+
{ refresh: async () => Promise.reject(new Error("offline")) },
|
|
107
|
+
// @windy-module system.license begin
|
|
108
|
+
licenseProvider(),
|
|
109
|
+
// @windy-module system.license end
|
|
110
|
+
authProvider(),
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
await router.push("/admin");
|
|
114
|
+
|
|
115
|
+
expect(router.currentRoute.value.name).toBe("service-unavailable");
|
|
116
|
+
expect(router.currentRoute.value.query).toMatchObject({
|
|
117
|
+
reason: "snapshot-unavailable",
|
|
118
|
+
from: "/admin",
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// @windy-module system.license begin
|
|
123
|
+
test("License 快照 401 会清退到登录页", async () => {
|
|
124
|
+
const router = createAppRouter(
|
|
125
|
+
createMemoryHistory(),
|
|
126
|
+
provider(accessSnapshot()),
|
|
127
|
+
{ refresh: async () => Promise.reject(new ApiError("expired", 401)) },
|
|
128
|
+
authProvider(),
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
await router.push("/admin/system/license");
|
|
132
|
+
|
|
133
|
+
expect(router.currentRoute.value.name).toBe("login");
|
|
134
|
+
expect(router.currentRoute.value.query.returnTo).toBe(
|
|
135
|
+
"/admin/system/license",
|
|
136
|
+
);
|
|
137
|
+
});
|
|
138
|
+
// @windy-module system.license end
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
function offlineAuthProvider(error: Error) {
|
|
142
|
+
return {
|
|
143
|
+
ensureInitialized: async () => Promise.reject(error),
|
|
144
|
+
clear() {},
|
|
145
|
+
};
|
|
146
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { safeReturnTo } from "./auth-navigation";
|
|
3
|
+
|
|
4
|
+
describe("安全 returnTo", () => {
|
|
5
|
+
test("只接受应用内绝对路径并排除登录页自身", () => {
|
|
6
|
+
expect(safeReturnTo("/admin/system/users?tab=active#top")).toBe(
|
|
7
|
+
"/admin/system/users?tab=active#top",
|
|
8
|
+
);
|
|
9
|
+
expect(safeReturnTo("https://evil.test/admin/system/users")).toBe("/");
|
|
10
|
+
expect(safeReturnTo("//evil.test/admin/system/users")).toBe("/");
|
|
11
|
+
expect(safeReturnTo("/\\evil.test/admin/system/users")).toBe("/");
|
|
12
|
+
expect(safeReturnTo("/login?returnTo=/admin/system/users")).toBe("/");
|
|
13
|
+
});
|
|
14
|
+
});
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
import DashboardPage from "@/pages/dashboard/DashboardPage.vue";
|
|
8
8
|
import HomePage from "@/app/home/HomePage.vue";
|
|
9
9
|
import AccessDeniedPage from "@/pages/AccessDeniedPage.vue";
|
|
10
|
+
import ServiceUnavailablePage from "@/pages/ServiceUnavailablePage.vue";
|
|
10
11
|
import LoginPage from "@/pages/auth/LoginPage.vue";
|
|
11
12
|
import ChangePasswordPage from "@/pages/auth/ChangePasswordPage.vue";
|
|
12
13
|
// @windy-module system.license begin
|
|
@@ -106,6 +107,16 @@ export const routes: RouteRecordRaw[] = [
|
|
|
106
107
|
}),
|
|
107
108
|
meta: { title: "无法访问", layout: "auth" },
|
|
108
109
|
},
|
|
110
|
+
{
|
|
111
|
+
path: "/service-unavailable",
|
|
112
|
+
name: "service-unavailable",
|
|
113
|
+
component: ServiceUnavailablePage,
|
|
114
|
+
props: (route) => ({
|
|
115
|
+
reason: String(route.query.reason || "snapshot-unavailable"),
|
|
116
|
+
from: safeReturnTo(String(route.query.from || "/")),
|
|
117
|
+
}),
|
|
118
|
+
meta: { title: "服务暂不可用", layout: "auth", public: true },
|
|
119
|
+
},
|
|
109
120
|
systemRoute("system.user", "users"),
|
|
110
121
|
systemRoute("system.role", "roles"),
|
|
111
122
|
systemRoute("system.department", "departments"),
|
|
@@ -157,10 +168,8 @@ export function createAppRouter(
|
|
|
157
168
|
try {
|
|
158
169
|
session = await authProvider.ensureInitialized();
|
|
159
170
|
} catch {
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
? true
|
|
163
|
-
: deniedRoute(to.fullPath, "session-unavailable");
|
|
171
|
+
if (to.name === "login") return true;
|
|
172
|
+
return serviceUnavailableRoute(to.fullPath, "session-unavailable");
|
|
164
173
|
}
|
|
165
174
|
if (to.name === "login") {
|
|
166
175
|
if (session.phase !== "authenticated") return true;
|
|
@@ -191,12 +200,9 @@ export function createAppRouter(
|
|
|
191
200
|
authProvider.clear();
|
|
192
201
|
return loginRoute(to.fullPath);
|
|
193
202
|
}
|
|
194
|
-
return
|
|
195
|
-
to.fullPath,
|
|
196
|
-
|
|
197
|
-
? "admin-access-denied"
|
|
198
|
-
: "snapshot-unavailable",
|
|
199
|
-
);
|
|
203
|
+
return isApiErrorStatus(error, 403)
|
|
204
|
+
? deniedRoute(to.fullPath, "admin-access-denied")
|
|
205
|
+
: serviceUnavailableRoute(to.fullPath, "snapshot-unavailable");
|
|
200
206
|
}
|
|
201
207
|
|
|
202
208
|
// @windy-module system.license begin
|
|
@@ -208,7 +214,7 @@ export function createAppRouter(
|
|
|
208
214
|
authProvider.clear();
|
|
209
215
|
return loginRoute(to.fullPath);
|
|
210
216
|
}
|
|
211
|
-
return
|
|
217
|
+
return serviceUnavailableRoute(to.fullPath, "snapshot-unavailable");
|
|
212
218
|
}
|
|
213
219
|
if (licenseSnapshot.restricted && to.name !== "runtime-license") {
|
|
214
220
|
return {
|
|
@@ -243,4 +249,12 @@ function deniedRoute(from: string, reason: string) {
|
|
|
243
249
|
};
|
|
244
250
|
}
|
|
245
251
|
|
|
252
|
+
function serviceUnavailableRoute(from: string, reason: string) {
|
|
253
|
+
return {
|
|
254
|
+
name: "service-unavailable",
|
|
255
|
+
query: { from, reason },
|
|
256
|
+
replace: true,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
246
260
|
export const router = createAppRouter();
|