create-windy 0.2.5 → 0.2.7
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 +13 -0
- package/dist/cli.js +179 -105
- 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 +23 -7
- package/template/apps/server/src/auth/password.ts +2 -4
- package/template/apps/server/src/persistence.ts +2 -0
- package/template/apps/web/src/components/auth/PasswordChangeForm.vue +52 -7
- package/template/apps/web/src/components/auth/PasswordChangeForm.webtest.ts +43 -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/template/packages/shared/index.ts +1 -0
- package/template/packages/shared/src/password.ts +4 -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,32 +19,47 @@ 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
|
|
|
55
|
+
全新数据库的初始平台账号为 `admin`。初始密码不是所有项目共用的固定值,而是
|
|
56
|
+
create-windy 在本机 `.env` 中随机生成的 `WINDY_BOOTSTRAP_ADMIN_PASSWORD`;该文件已
|
|
57
|
+
被 Git 忽略,请在本机直接查看,不要把密码提交到仓库或复制到文档。首次登录会要求
|
|
58
|
+
立即修改密码,新密码必须为 12-128 个字符,且不能与当前密码相同。
|
|
59
|
+
|
|
60
|
+
`.env` 只负责全新数据库的首次初始化。账号密码写入数据库后,重启不会用环境变量
|
|
61
|
+
覆盖已有密码;如果已经完成首次改密,应使用修改后的密码登录。
|
|
62
|
+
|
|
48
63
|
| 路径 | 访问要求 | 说明 |
|
|
49
64
|
| ---- | -------- | ---- |
|
|
50
65
|
| `/login` | 公开 | 登录成功后回到安全的站内目标,默认进入 `/` |
|
|
@@ -96,6 +111,7 @@ bun run dev:web
|
|
|
96
111
|
## 常用命令
|
|
97
112
|
|
|
98
113
|
```bash
|
|
114
|
+
bun run dev
|
|
99
115
|
bun run dev:web
|
|
100
116
|
bun run dev:server
|
|
101
117
|
bun run typecheck
|
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
import argon2, { argon2id } from "argon2";
|
|
2
|
+
import { PASSWORD_POLICY } from "@windy/shared";
|
|
2
3
|
|
|
3
|
-
export
|
|
4
|
-
minLength: 12,
|
|
5
|
-
maxLength: 128,
|
|
6
|
-
} as const;
|
|
4
|
+
export { PASSWORD_POLICY } from "@windy/shared";
|
|
7
5
|
|
|
8
6
|
const ARGON2_OPTIONS = {
|
|
9
7
|
type: argon2id,
|
|
@@ -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
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { computed, shallowRef } from "vue";
|
|
3
3
|
import { KeyRoundIcon, LoaderCircleIcon } from "@lucide/vue";
|
|
4
|
+
import { PASSWORD_POLICY } from "@windy/shared";
|
|
4
5
|
import { Button } from "@/components/ui/button";
|
|
5
6
|
import { Input } from "@/components/ui/input";
|
|
6
7
|
import type { PasswordChangeInput } from "@/services/auth-api";
|
|
@@ -11,7 +12,20 @@ const emit = defineEmits<{ submit: [input: PasswordChangeInput] }>();
|
|
|
11
12
|
const currentPassword = shallowRef("");
|
|
12
13
|
const newPassword = shallowRef("");
|
|
13
14
|
const confirmation = shallowRef("");
|
|
14
|
-
const
|
|
15
|
+
const newPasswordError = computed(() => {
|
|
16
|
+
if (!newPassword.value) return "";
|
|
17
|
+
if (newPassword.value.length < PASSWORD_POLICY.minLength) {
|
|
18
|
+
return `新密码至少需要 ${PASSWORD_POLICY.minLength} 个字符,当前为 ${newPassword.value.length} 个。`;
|
|
19
|
+
}
|
|
20
|
+
if (newPassword.value.length > PASSWORD_POLICY.maxLength) {
|
|
21
|
+
return `新密码不能超过 ${PASSWORD_POLICY.maxLength} 个字符。`;
|
|
22
|
+
}
|
|
23
|
+
if (currentPassword.value && newPassword.value === currentPassword.value) {
|
|
24
|
+
return "新密码不能与当前密码相同。";
|
|
25
|
+
}
|
|
26
|
+
return "";
|
|
27
|
+
});
|
|
28
|
+
const confirmationError = computed(() => {
|
|
15
29
|
if (confirmation.value && confirmation.value !== newPassword.value) {
|
|
16
30
|
return "两次输入的新密码不一致";
|
|
17
31
|
}
|
|
@@ -21,7 +35,9 @@ const canSubmit = computed(
|
|
|
21
35
|
() =>
|
|
22
36
|
!props.loading &&
|
|
23
37
|
currentPassword.value.length > 0 &&
|
|
24
|
-
newPassword.value.length
|
|
38
|
+
newPassword.value.length > 0 &&
|
|
39
|
+
!newPasswordError.value &&
|
|
40
|
+
!confirmationError.value &&
|
|
25
41
|
newPassword.value === confirmation.value,
|
|
26
42
|
);
|
|
27
43
|
|
|
@@ -54,12 +70,31 @@ function submit() {
|
|
|
54
70
|
v-model="newPassword"
|
|
55
71
|
type="password"
|
|
56
72
|
autocomplete="new-password"
|
|
57
|
-
minlength="
|
|
58
|
-
maxlength="
|
|
73
|
+
:minlength="PASSWORD_POLICY.minLength"
|
|
74
|
+
:maxlength="PASSWORD_POLICY.maxLength"
|
|
75
|
+
:aria-describedby="
|
|
76
|
+
newPasswordError
|
|
77
|
+
? 'new-password-help new-password-error'
|
|
78
|
+
: 'new-password-help'
|
|
79
|
+
"
|
|
80
|
+
:aria-invalid="Boolean(newPasswordError)"
|
|
59
81
|
:disabled="loading"
|
|
60
82
|
required
|
|
61
83
|
/>
|
|
62
|
-
<p class="text-xs text-muted-foreground"
|
|
84
|
+
<p id="new-password-help" class="text-xs text-muted-foreground">
|
|
85
|
+
密码要求:{{ PASSWORD_POLICY.minLength }}-{{
|
|
86
|
+
PASSWORD_POLICY.maxLength
|
|
87
|
+
}}
|
|
88
|
+
个字符,且不能与当前密码相同。
|
|
89
|
+
</p>
|
|
90
|
+
<p
|
|
91
|
+
v-if="newPasswordError"
|
|
92
|
+
id="new-password-error"
|
|
93
|
+
class="text-sm text-destructive"
|
|
94
|
+
role="alert"
|
|
95
|
+
>
|
|
96
|
+
{{ newPasswordError }}
|
|
97
|
+
</p>
|
|
63
98
|
</div>
|
|
64
99
|
<div class="space-y-2">
|
|
65
100
|
<label for="confirm-password" class="text-sm font-medium"
|
|
@@ -70,12 +105,22 @@ function submit() {
|
|
|
70
105
|
v-model="confirmation"
|
|
71
106
|
type="password"
|
|
72
107
|
autocomplete="new-password"
|
|
108
|
+
:aria-describedby="confirmationError ? 'confirmation-error' : undefined"
|
|
109
|
+
:aria-invalid="Boolean(confirmationError)"
|
|
73
110
|
:disabled="loading"
|
|
74
111
|
required
|
|
75
112
|
/>
|
|
113
|
+
<p
|
|
114
|
+
v-if="confirmationError"
|
|
115
|
+
id="confirmation-error"
|
|
116
|
+
class="text-sm text-destructive"
|
|
117
|
+
role="alert"
|
|
118
|
+
>
|
|
119
|
+
{{ confirmationError }}
|
|
120
|
+
</p>
|
|
76
121
|
</div>
|
|
77
|
-
<p v-if="
|
|
78
|
-
{{
|
|
122
|
+
<p v-if="error" class="text-sm text-destructive" role="alert">
|
|
123
|
+
{{ error }}
|
|
79
124
|
</p>
|
|
80
125
|
<Button type="submit" class="w-full" :disabled="!canSubmit">
|
|
81
126
|
<LoaderCircleIcon
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { mount } from "@vue/test-utils";
|
|
2
|
+
import { describe, expect, test } from "vitest";
|
|
3
|
+
import PasswordChangeForm from "./PasswordChangeForm.vue";
|
|
4
|
+
|
|
5
|
+
describe("首次改密表单", () => {
|
|
6
|
+
test("输入过短密码时立即说明原因并保持提交禁用", async () => {
|
|
7
|
+
const wrapper = mount(PasswordChangeForm);
|
|
8
|
+
|
|
9
|
+
await wrapper.get("#current-password").setValue("initial-password");
|
|
10
|
+
await wrapper.get("#new-password").setValue("weak");
|
|
11
|
+
await wrapper.get("#confirm-password").setValue("weak");
|
|
12
|
+
|
|
13
|
+
expect(wrapper.get("[role='alert']").text()).toBe(
|
|
14
|
+
"新密码至少需要 12 个字符,当前为 4 个。",
|
|
15
|
+
);
|
|
16
|
+
expect(wrapper.get("button[type='submit']").attributes()).toHaveProperty(
|
|
17
|
+
"disabled",
|
|
18
|
+
);
|
|
19
|
+
expect(wrapper.emitted("submit")).toBeUndefined();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("满足规则且两次一致后允许提交", async () => {
|
|
23
|
+
const wrapper = mount(PasswordChangeForm);
|
|
24
|
+
|
|
25
|
+
await wrapper.get("#current-password").setValue("initial-password");
|
|
26
|
+
await wrapper.get("#new-password").setValue("new-password-2026");
|
|
27
|
+
await wrapper.get("#confirm-password").setValue("new-password-2026");
|
|
28
|
+
|
|
29
|
+
expect(wrapper.find("[role='alert']").exists()).toBe(false);
|
|
30
|
+
expect(
|
|
31
|
+
wrapper.get("button[type='submit']").attributes("disabled"),
|
|
32
|
+
).toBeUndefined();
|
|
33
|
+
await wrapper.get("form").trigger("submit");
|
|
34
|
+
expect(wrapper.emitted("submit")).toEqual([
|
|
35
|
+
[
|
|
36
|
+
{
|
|
37
|
+
currentPassword: "initial-password",
|
|
38
|
+
newPassword: "new-password-2026",
|
|
39
|
+
},
|
|
40
|
+
],
|
|
41
|
+
]);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -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();
|